diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..831183f Binary files /dev/null and b/.DS_Store differ diff --git a/TUICallKit-Vue3/build/TANG-DETECTIVE-NATIVE.md b/TUICallKit-Vue3/build/TANG-DETECTIVE-NATIVE.md new file mode 100644 index 0000000..6684169 --- /dev/null +++ b/TUICallKit-Vue3/build/TANG-DETECTIVE-NATIVE.md @@ -0,0 +1,28 @@ +# 唐侦探原生页面接入 + +`native/tang-detective/` 是原生微信页面、数据和素材的原字节快照。原 `app.js` 和 `sitemap.json` 不导入;`app.json` 仅用作构建元数据,`app.wxss` 输出为游戏页面局部导入的 `shared.wxss`。不要直接修改快照。源文件哈希登记在 `tang-detective-source-manifest.json`。 + +`tang-detective-native-plugin.mjs` 仅在微信构建启用。它以 `writeBundle: { order: 'post', sequential: true }` 在 uni 产物生成后完成以下工作,监听重建时同样执行: + +1. 将原页面和分包输出到 `tang-detective/` 命名空间,修改本地绝对路径、动态分包根及路径白名单。 +2. 给 24 个页面补齐横屏、自定义导航配置和局部共享样式。 +3. 逐字节覆盖 `native-adapter/tang-detective/` 的适配文件;适配文件必须写最终输出路径,不进行第二次路径替换。 +4. 将全部页面的唯一顶层 `Page({` 注册转换为相对引入根 `utils/tangPage.js` 的调用;由适配层负责账号存档初始化。适配页面保留原 `Page({` 写法。 +5. 根据 `vite.config.ts` 传入的 `API_BASE_URL` 生成 CommonJS `utils/platformConfig.js`。不读取密钥和原应用入口。 +6. 增量合并宿主 `app.json` 的页面、分包和预加载;不覆盖宿主 `app.js`、全局样式、工程配置和其他页面。 + +适配层不能替换媒体、应用入口或导入状态。`.native-import-state.json` 记录输出文件归属和哈希;只删除清单内且未经外部修改的过期文件,避免误删用户文件。它不会清理其他命名空间。 + +运行检查: + +```sh +node --test build/tang-detective-native-plugin.test.mjs +npm run build:mp-weixin +node build/validate-tang-detective-output.mjs +``` + +静态测试覆盖 473 份源文件哈希、204 份媒体、24 个原生页面、120 张章节画页的实际图片选择、第一章两个播放器的包内资源、全部相对模块依赖、重复构建、宿主文件保护与适配文件保护。构建后校验脚本进一步检查当前真实产物并输出各包原始文件字节数。 + +静态导入不代表微信原生 WXML 编译、模拟器/真机播放、包体积验收或上传通过。原生全量内容约 28.6 MiB,接入会增加主包内容,必须保留实际体积报告。H5 构建不会包含原生页面;完整 H5 体验需要独立页面适配,不能据此声称跨端可玩。 + +如需刷新快照,先核对来源变化,再运行 `node build/import-tang-detective-native.mjs <源 miniprogram 目录>`。脚本遇到已存在但哈希不同的文件会停止,避免无提示覆盖已有快照;差异更新需要单独审查。 diff --git a/TUICallKit-Vue3/build/import-tang-detective-native.mjs b/TUICallKit-Vue3/build/import-tang-detective-native.mjs new file mode 100644 index 0000000..8ad3818 --- /dev/null +++ b/TUICallKit-Vue3/build/import-tang-detective-native.mjs @@ -0,0 +1,30 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { listFiles, sha256 } from './tang-detective-native-plugin.mjs' + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const sourceDirectory = process.argv[2] +if (!sourceDirectory) throw new Error('Usage: node build/import-tang-detective-native.mjs ') +const targetDirectory = path.join(projectRoot, 'native/tang-detective') +const allowedRootFiles = new Set(['app.json', 'app.wxss']) +const allowedDirectories = /^(?:assets|data|utils|pages|package-[a-z0-9-]+)\// +const selectedFiles = listFiles(sourceDirectory).filter(relative => allowedRootFiles.has(relative) || allowedDirectories.test(relative)) +if (!selectedFiles.includes('app.json') || !selectedFiles.includes('pages/home/home.js')) throw new Error('Source is not the expected native Tang Detective program') +const files = selectedFiles.map(relative => { + const bytes = fs.readFileSync(path.join(sourceDirectory, relative)) + const destination = path.join(targetDirectory, relative) + const hash = sha256(bytes) + if (fs.existsSync(destination) && sha256(fs.readFileSync(destination)) !== hash) { + throw new Error(`Existing snapshot differs; refusing to overwrite: ${relative}`) + } + return { path: relative, bytes: bytes.length, sha256: hash } +}) +for (const file of files) { + const destination = path.join(targetDirectory, file.path) + fs.mkdirSync(path.dirname(destination), { recursive: true }) + fs.copyFileSync(path.join(sourceDirectory, file.path), destination) +} +const manifest = { version: 1, excludedRootFiles: ['app.js', 'sitemap.json'], files } +fs.writeFileSync(path.join(projectRoot, 'build/tang-detective-source-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`) +console.log(`Imported ${files.length} byte-preserved native files; no source App or project configuration was imported.`) diff --git a/TUICallKit-Vue3/build/tang-detective-cos-media.mjs b/TUICallKit-Vue3/build/tang-detective-cos-media.mjs new file mode 100644 index 0000000..d5dce2e --- /dev/null +++ b/TUICallKit-Vue3/build/tang-detective-cos-media.mjs @@ -0,0 +1,212 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import vm from 'node:vm' +import { fileURLToPath } from 'node:url' + +const buildDirectory = path.dirname(fileURLToPath(import.meta.url)) +export const DEFAULT_MEDIA_MANIFEST_PATH = path.join(buildDirectory, 'tang-detective-cos-manifest.json') +export const SOURCE_MANIFEST_PATH = path.join(buildDirectory, 'tang-detective-source-manifest.json') +const RUNTIME_HELPER_PATH = path.resolve(buildDirectory, '../native-adapter/tang-detective/utils/cosMedia.js') +const SHA256 = /^[a-f0-9]{64}$/ +const TYPES = { + '.jpg': ['image', 'image/jpeg'], '.jpeg': ['image', 'image/jpeg'], + '.png': ['image', 'image/png'], '.webp': ['image', 'image/webp'], + '.gif': ['image', 'image/gif'], '.svg': ['image', 'image/svg+xml'], '.avif': ['image', 'image/avif'], + '.mp3': ['audio', 'audio/mpeg'], '.wav': ['audio', 'audio/wav'], + '.aac': ['audio', 'audio/aac'], '.m4a': ['audio', 'audio/mp4'], '.ogg': ['audio', 'audio/ogg'], + '.mp4': ['video', 'video/mp4'], '.webm': ['video', 'video/webm'], '.mov': ['video', 'video/quicktime'], +} +export const isMediaFile = value => Boolean(TYPES[path.extname(value).toLowerCase()]) +const hash = value => crypto.createHash('sha256').update(value).digest('hex') +const json = value => `${JSON.stringify(value, null, 2)}\n` + +function assert(condition, message) { + if (!condition) throw new Error(`Invalid Tang Detective COS manifest: ${message}`) +} + +function safeRelative(value) { + return typeof value === 'string' && value.length > 0 && !value.startsWith('/') + && !/[\\?#\u0000-\u0020]/.test(value) + && value.split('/').every(part => part && part !== '.' && part !== '..') +} + +function httpsUrl(value) { + try { + assert(typeof value === 'string' && !/["'`<>\\\s]/.test(value), 'URL contains unsafe literal characters') + const parsed = new URL(value) + assert(parsed.protocol === 'https:' && !parsed.username && !parsed.password + && !parsed.search && !parsed.hash, 'URLs must use unsigned HTTPS') + return parsed + } catch (error) { + throw new Error(`Invalid Tang Detective COS manifest: invalid HTTPS URL (${error.message})`) + } +} + +export function validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath = SOURCE_MANIFEST_PATH } = {}) { + assert(mediaManifest && mediaManifest.schemaVersion === 1, 'schemaVersion must be 1') + const sourceBytes = fs.readFileSync(sourceManifestPath) + assert(mediaManifest.sourceManifestSha256 === hash(sourceBytes), 'source manifest SHA-256 mismatch') + const sourceManifest = JSON.parse(sourceBytes) + const expected = new Map(sourceManifest.files.filter(file => isMediaFile(file.path)).map(file => [file.path, file])) + const destination = mediaManifest.destination + assert(destination && typeof destination.bucket === 'string' && destination.bucket.trim() + && typeof destination.region === 'string' && destination.region.trim(), 'destination bucket and region are required') + const base = httpsUrl(destination.baseUrl) + assert(Array.isArray(mediaManifest.entries) && mediaManifest.entries.length === expected.size, 'media coverage is incomplete') + const entries = new Map() + const objects = new Map() + for (const entry of mediaManifest.entries) { + assert(entry && safeRelative(entry.sourcePath) && expected.has(entry.sourcePath), 'unknown or unsafe sourcePath') + assert(!entries.has(entry.sourcePath), `duplicate sourcePath: ${entry.sourcePath}`) + const recorded = expected.get(entry.sourcePath) + const [kind, contentType] = TYPES[path.extname(entry.sourcePath).toLowerCase()] + assert(entry.kind === kind && entry.contentType === contentType, `media type mismatch: ${entry.sourcePath}`) + assert(SHA256.test(entry.sha256) && entry.sha256 === recorded.sha256 + && Number.isSafeInteger(entry.bytes) && entry.bytes === recorded.bytes, `source metadata mismatch: ${entry.sourcePath}`) + const actual = fs.readFileSync(path.join(sourceDirectory, entry.sourcePath)) + assert(actual.length === entry.bytes && hash(actual) === entry.sha256, `source bytes changed: ${entry.sourcePath}`) + assert(entry.uploaded === true && entry.remoteVerifiedSha256 === entry.sha256, `remote verification missing: ${entry.sourcePath}`) + const expectedObjectKey = `tang-detective/season-01/media-v1/${entry.sha256}${path.extname(entry.sourcePath)}` + assert(entry.objectKey === expectedObjectKey, `object key does not match immutable media contract: ${entry.sourcePath}`) + if (kind === 'audio' || kind === 'video') { + assert(entry.rangeVerified === true, `media range verification missing: ${entry.sourcePath}`) + } + const url = httpsUrl(entry.url) + const expectedUrl = `${base.href.replace(/\/$/, '')}/${entry.objectKey.split('/').map(encodeURIComponent).join('/')}` + assert(url.href === expectedUrl && entry.url === url.href, `URL does not match destination and object key: ${entry.sourcePath}`) + const identity = `${entry.sha256}:${entry.bytes}:${entry.contentType}` + assert(!objects.has(entry.url) || objects.get(entry.url) === identity, `conflicting object contents: ${entry.sourcePath}`) + objects.set(entry.url, identity) + entries.set(entry.sourcePath, Object.freeze({ ...entry })) + } + return { entries, sourceManifestSha256: mediaManifest.sourceManifestSha256, + manifestSha256: hash(json(mediaManifest)), destination: { ...destination }, objectCount: objects.size } +} + +export function loadCosMediaManifest({ mediaManifest, mediaManifestPath = DEFAULT_MEDIA_MANIFEST_PATH, sourceDirectory, sourceManifestPath } = {}) { + // Explicit null is useful for a local/offline comparison without touching a + // verified manifest owned by another task. Undefined uses automatic discovery. + if (mediaManifest === null) return null + if (mediaManifest === undefined) { + if (!fs.existsSync(mediaManifestPath)) return null + mediaManifest = JSON.parse(fs.readFileSync(mediaManifestPath, 'utf8')) + } + return validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath }) +} + +function relativeHelper(relative, helper = 'utils/cosMedia.js') { + const result = path.posix.relative(path.posix.dirname(relative), helper) + return result.startsWith('.') ? result : `./${result}` +} + +function replaceOnce(source, before, after, filename) { + assert(source.split(before).length === 2, `conversion anchor changed: ${filename}`) + return source.replace(before, after) +} + +function evaluateData(source, filename) { + const context = { module: { exports: {} } } + vm.runInNewContext(source, context, { filename, timeout: 1000 }) + return context.module.exports +} + +export function applyCosMediaOutput(files, media, { namespace = 'tang-detective' } = {}) { + if (!media) return + const byUrl = new Map([...media.entries.values()].map(entry => [entry.url, entry])) + const lookup = value => { + if (byUrl.has(value)) return byUrl.get(value) + let relative = value.replace(/^\//, '') + if (relative.startsWith(`${namespace}/`)) relative = relative.slice(namespace.length + 1) + return media.entries.get(relative) + } + const staticPath = /(["'`])(\/(?:[a-z0-9-]+\/)?(?:assets|package-[a-z0-9-]+)\/[^"'`\n$]*\.(?:jpe?g|png|webp|gif|svg|avif|mp3|wav|aac|m4a|ogg|mp4|webm|mov))\1/gi + for (const [relative, bytes] of files) { + if (isMediaFile(relative)) { files.delete(relative); continue } + if (!/\.(?:js|json|wxml|wxss|wxs)$/.test(relative) || relative === 'utils/cosMedia.js') continue + let source = bytes.toString('utf8') + const helper = `require(${JSON.stringify(relativeHelper(relative))})` + if (relative.endsWith('/data/releaseAssetManifest.js')) { + const exported = evaluateData(source, relative) + for (const asset of Object.values(exported.releaseAssets)) { + if (!asset.localSeed) continue + const entry = lookup(asset.localSeed) + assert(entry && entry.sha256 === asset.sha256 && entry.kind === asset.kind, `release asset mismatch: ${relative}`) + asset.localSeed = '' + asset.remoteUrl = entry.url + } + const share = media.entries.get('assets/share/guixiang-story-share-preview-v1.jpg') + assert(share, 'share preview entry is missing') + exported.releaseAssets['image.tang.share-preview'] = { + kind: 'image', localSeed: '', remoteUrl: share.url, + remotePath: share.objectKey, sha256: share.sha256, + } + files.set(relative, Buffer.from(`module.exports = ${json(exported)}`)) + continue + } + source = source.replace(staticPath, (match, quote, value) => { + const entry = lookup(value) + assert(entry, `unregistered static media: ${relative}: ${value}`) + return `${quote}${entry.url}${quote}` + }) + if (relative.endsWith('/pages/chapter/chapterPages.js')) { + source = replaceOnce(source, ' pageSequence = attachPlayableVisuals(pageSequence, chapter)', + ` pageSequence = ${helper}.mapMedia(pageSequence)\n pageSequence = attachPlayableVisuals(pageSequence, chapter)`, relative) + } + if (relative.endsWith('/data/playableVisualPolicy.js')) { + const previous = ' && clean(releaseAsset.localSeed)\n && clean(releaseAsset.localSeed) === clean(page.illustrationAsset),' + source = replaceOnce(source, previous, + ` && ${helper}.verifiedUrl(releaseAsset.remoteUrl, releaseAsset.sha256, 'image')\n && clean(releaseAsset.remoteUrl) === clean(page.illustrationAsset),`, relative) + } + if (relative.endsWith('/utils/comicPageModel.js')) { + source = replaceOnce(source, ' if (localSeed) {\n return {\n src: localSeed,', + ` const remoteUrl = releaseAsset && ${helper}.verifiedUrl(\n releaseAsset.remoteUrl, releaseAsset.sha256, 'image',\n )\n if (localSeed || remoteUrl) {\n return {\n src: localSeed || remoteUrl,`, relative) + source = replaceOnce(source, " source: 'local-seed',", " source: localSeed ? 'local-seed' : 'remote-url',", relative) + // Keep getReviewedAudioSrc and isPackagedPath fail-closed. Approved + // remote full-page audio uses the existing asynchronous verified player. + } + if (relative.endsWith('/utils/assetManager.js')) { + source = replaceOnce(source, ' if (!cdnBaseUrl || !asset.remotePath) {', + ` const remoteUrl = asset.remoteUrl\n ? ${helper}.verifiedUrl(asset.remoteUrl, asset.sha256, asset.kind) : ''\n if (asset.remoteUrl && !remoteUrl) return fallback(assetId, 'remote-integrity-failed')\n if (!remoteUrl && (!cdnBaseUrl || !asset.remotePath)) {`, relative) + source = replaceOnce(source, 'download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath))', + 'download(assetPlatform, remoteUrl || joinPath(cdnBaseUrl, asset.remotePath))', relative) + } + if (relative.endsWith('/pages/chapter/chapter.js')) { + const start = source.indexOf(' prepareSharePreview() {') + const end = source.indexOf(' loadChapter(', start) + assert(start !== -1 && end > start, `share conversion anchor changed: ${relative}`) + source = source.slice(0, start) + + ` prepareSharePreview() {\n return ${helper}.prepareSharePreview(this, this.getAssetManager())\n },\n\n` + + source.slice(end) + source = replaceOnce(source, " const localPath = String(this.data.sharePreviewLocalPath || '').trim()", + " const localPath = '' // Cached share bytes are revalidated in prepareSharePreview.", relative) + source = replaceOnce(source, ' if (!pageData.currentPage) return', + ` if (!pageData.currentPage) return\n ${helper}.beginComicImage(this, pageData)`, relative) + source = replaceOnce(source, ' onComicImageLoad() {', + ` onComicImageLoad(event) {\n if (!${helper}.isCurrentComicImageEvent(this, event)) return`, relative) + const errorStart = source.indexOf(' onComicImageError() {') + const errorEnd = source.indexOf(' applyLayoutMetrics(', errorStart) + assert(errorStart !== -1 && errorEnd > errorStart, `image error conversion anchor changed: ${relative}`) + let handler = source.slice(errorStart, errorEnd) + handler = replaceOnce(handler, ' onComicImageError() {', + ` onComicImageError(event) {\n if (!${helper}.recordComicImageError(this, event)) return`, relative) + handler = replaceOnce(handler, ' actorFallback\n', + ` actorFallback\n && ${helper}.canUseComicFallback(this, actorFallback)\n`, relative) + handler = replaceOnce(handler, ' fallback\n', + ` fallback\n && ${helper}.canUseComicFallback(this, fallback)\n`, relative) + handler = handler.replaceAll('this.setData({', `${helper}.applyComicImageFallback(this, {`) + source = source.slice(0, errorStart) + handler + source.slice(errorEnd) + } + if (relative.endsWith('/pages/chapter/chapter.wxml')) { + assert(source.includes('binderror="onComicImageError"'), `image event conversion anchor changed: ${relative}`) + source = source.replaceAll('binderror="onComicImageError"', + 'data-cos-page-id="{{currentPageId}}" data-cos-image-src="{{comicImageSrc}}" data-cos-image-generation="{{comicImageGeneration}}" binderror="onComicImageError"') + } + files.set(relative, Buffer.from(source)) + } + files.set('utils/cosMedia.js', fs.readFileSync(RUNTIME_HELPER_PATH)) + files.set('utils/cosMediaManifest.js', Buffer.from(`module.exports = ${json({ + namespace, + entries: [...media.entries.values()].map(({ sourcePath, kind, bytes, sha256, url }) => ({ sourcePath, kind, bytes, sha256, url })), + })}`)) +} diff --git a/TUICallKit-Vue3/build/tang-detective-cos-media.test.mjs b/TUICallKit-Vue3/build/tang-detective-cos-media.test.mjs new file mode 100644 index 0000000..2bd90f2 --- /dev/null +++ b/TUICallKit-Vue3/build/tang-detective-cos-media.test.mjs @@ -0,0 +1,522 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import vm from 'node:vm' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { copyNativeProgram, listFiles, sha256 } from './tang-detective-native-plugin.mjs' +import { isMediaFile, loadCosMediaManifest, validateCosMediaManifest, SOURCE_MANIFEST_PATH } from './tang-detective-cos-media.mjs' +import { validateNativeOutput } from './validate-tang-detective-output.mjs' + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const sourceDirectory = path.join(projectRoot, 'native/tang-detective') +const overlayDirectory = path.join(projectRoot, 'native-adapter/tang-detective') +const sourceBytes = fs.readFileSync(SOURCE_MANIFEST_PATH) +const sourceManifest = JSON.parse(sourceBytes) +const sourceRequire = createRequire(path.join(projectRoot, 'source-cos-test.cjs')) +const sourceMedia = sourceManifest.files.filter(file => isMediaFile(file.path)) + +function write(directory, relative, value) { + const filename = path.join(directory, relative) + fs.mkdirSync(path.dirname(filename), { recursive: true }) + fs.writeFileSync(filename, value) +} + +function fakeVerifiedManifest() { + // Deliberately test-only, reserved .test domain. This is never written to the + // real manifest location and is not evidence of any external upload. + const baseUrl = 'https://tang-assets.example.test' + return { + schemaVersion: 1, sourceManifestSha256: sha256(sourceBytes), + destination: { bucket: 'test-bucket', region: 'test-region', baseUrl }, + entries: sourceMedia.map(file => { + const kind = file.path.endsWith('.jpg') ? 'image' : 'audio' + const objectKey = `tang-detective/season-01/media-v1/${file.sha256}${path.extname(file.path)}` + return { sourcePath: file.path, kind, contentType: kind === 'image' ? 'image/jpeg' : 'audio/mpeg', + bytes: file.bytes, sha256: file.sha256, objectKey, url: `${baseUrl}/${objectKey}`, + uploaded: true, remoteVerifiedSha256: file.sha256, ...(kind === 'audio' ? { rangeVerified: true } : {}) } + }), + } +} + +function fixture(t, { remote = true } = {}) { + const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tang-cos-unit-'))) + // Every path removed below was created exclusively by this test. + t.after(() => fs.rmSync(temporary, { recursive: true, force: true })) + const outputDirectory = path.join(temporary, 'output') + write(outputDirectory, 'app.json', JSON.stringify({ pages: ['pages/index/index'], window: { navigationStyle: 'default' } })) + write(outputDirectory, 'app.js', '/* retained host entry */') + const mediaManifest = remote ? fakeVerifiedManifest() : null + const options = { sourceDirectory, overlayDirectory, outputDirectory, mediaManifest } + const report = copyNativeProgram(options) + const root = path.join(outputDirectory, 'tang-detective') + const localRequire = createRequire(path.join(outputDirectory, 'test.cjs')) + return { temporary, root, options, report, outputDirectory, mediaManifest, + require: relative => localRequire(path.join(root, relative)) } +} + +function mockPlatform(responses) { + const files = new Map() + const downloads = [] + let sequence = 0 + let failDownloads = false + const buffer = value => typeof value === 'string' ? Buffer.from(value) : Buffer.from(value) + const fsApi = { + mkdir: options => options.success({}), + access: options => files.has(options.path) ? options.success({}) : options.fail(new Error('missing')), + readFile(options) { + if (!files.has(options.filePath)) { options.fail(new Error('missing')); return } + const data = files.get(options.filePath) + options.success({ data: options.encoding === 'utf8' ? buffer(data).toString('utf8') : buffer(data) }) + }, + writeFile(options) { files.set(options.filePath, buffer(options.data)); options.success({}) }, + saveFile(options) { + if (!files.has(options.tempFilePath)) { options.fail(new Error('missing')); return } + files.set(options.filePath, files.get(options.tempFilePath)) + files.delete(options.tempFilePath) + options.success({ savedFilePath: options.filePath }) + }, + stat(options) { options.success({ stats: { size: buffer(files.get(options.path)).length } }) }, + unlink(options) { files.delete(options.filePath); options.success({}) }, + } + const platform = { + env: { USER_DATA_PATH: '/test-owned-user-data' }, + getFileSystemManager: () => fsApi, + downloadFile(options) { + downloads.push(options.url) + if (failDownloads || !responses.has(options.url)) { options.fail(new Error('network unavailable')); return } + const body = responses.get(options.url) + const tempFilePath = `/test-owned-temp/${++sequence}` + files.set(tempFilePath, Buffer.from(body)) + options.success({ statusCode: 200, tempFilePath, fileSize: body.length }) + }, + } + return { platform, files, downloads, failDownloads: value => { failDownloads = value } } +} + +function managerFixture(context) { + const { releaseAssets } = context.require('package-game/data/releaseAssetManifest.js') + const managerApi = context.require('package-game/utils/assetManager.js') + const responses = new Map(context.mediaManifest.entries.map(entry => [entry.url, fs.readFileSync(path.join(sourceDirectory, entry.sourcePath))])) + const mock = mockPlatform(responses) + const manager = managerApi.createAssetManager({ manifest: releaseAssets, assetPlatform: mock.platform, cdnBaseUrl: '' }) + return { ...mock, manager, managerApi, releaseAssets, responses } +} + +test('verified manifest requires all 204 original paths, hashes, exact HTTPS objects and upload receipts', () => { + const media = validateCosMediaManifest(fakeVerifiedManifest(), { sourceDirectory }) + assert.equal(media.entries.size, 204) + assert.equal(media.objectCount, 180) + const mutations = [ + manifest => { manifest.entries.pop() }, + manifest => { manifest.entries[1] = { ...manifest.entries[0] } }, + manifest => { manifest.sourceManifestSha256 = '0'.repeat(64) }, + manifest => { manifest.entries[0].sha256 = '0'.repeat(64) }, + manifest => { manifest.entries[0].bytes += 1 }, + manifest => { manifest.entries[0].remoteVerifiedSha256 = '0'.repeat(64) }, + manifest => { manifest.entries[0].uploaded = false }, + manifest => { manifest.entries[0].url = manifest.entries[0].url.replace('https:', 'http:') }, + manifest => { manifest.entries[0].url += '?signature=not-allowed' }, + manifest => { manifest.entries[0].url = 'https://other.example.test/file.jpg' }, + manifest => { manifest.destination.baseUrl = "https://tang-assets.example.test/quote'" }, + manifest => { manifest.entries[0].sourcePath = '../outside.jpg' }, + manifest => { manifest.entries[0].objectKey = 'mutable-name.jpg' }, + manifest => { + const entry = manifest.entries[0] + entry.objectKey = `wrong-prefix/${entry.sha256}${path.extname(entry.sourcePath)}` + entry.url = `${manifest.destination.baseUrl}/${entry.objectKey}` + }, + manifest => { + const entry = manifest.entries[0] + entry.objectKey = `tang-detective/season-01/media-v1/${entry.sha256.slice(0, 12)}${path.extname(entry.sourcePath)}` + entry.url = `${manifest.destination.baseUrl}/${entry.objectKey}` + }, + manifest => { delete manifest.entries.find(entry => entry.kind === 'audio').rangeVerified }, + manifest => { manifest.entries.find(entry => entry.kind === 'audio').rangeVerified = false }, + manifest => { manifest.entries[0].contentType = 'text/html' }, + ] + for (const mutate of mutations) { + const manifest = fakeVerifiedManifest() + mutate(manifest) + assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory }), /Invalid Tang Detective COS manifest/) + } +}) + +test('video manifest also requires a true range receipt and its exact immutable object key', t => { + const context = fixture(t) + const source = path.join(context.temporary, 'video-source') + const sourcePath = 'assets/video/test.mp4' + const bytes = Buffer.from('offline transport contract fixture') + write(source, sourcePath, bytes) + const record = { path: sourcePath, bytes: bytes.length, sha256: sha256(bytes) } + const smallSource = JSON.stringify({ files: [record] }) + write(context.temporary, 'video-source-manifest.json', smallSource) + const sourceManifestPath = path.join(context.temporary, 'video-source-manifest.json') + const objectKey = `tang-detective/season-01/media-v1/${record.sha256}.mp4` + const manifest = { ...context.mediaManifest, sourceManifestSha256: sha256(smallSource), entries: [{ + sourcePath, kind: 'video', contentType: 'video/mp4', bytes: record.bytes, sha256: record.sha256, + objectKey, url: `${context.mediaManifest.destination.baseUrl}/${objectKey}`, + uploaded: true, remoteVerifiedSha256: record.sha256, rangeVerified: true, + }] } + assert.equal(validateCosMediaManifest(manifest, { sourceDirectory: source, sourceManifestPath }).entries.size, 1) + for (const value of [undefined, false, 'true']) { + manifest.entries[0].rangeVerified = value + assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory: source, sourceManifestPath }), /range verification missing/) + } +}) + +test('source byte drift fails even when a manifest still claims the expected hash', t => { + const context = fixture(t) + const source = path.join(context.temporary, 'changed-source') + const original = context.mediaManifest.entries[0] + write(source, original.sourcePath, 'changed source bytes') + const smallSource = JSON.stringify({ files: [{ path: original.sourcePath, bytes: original.bytes, sha256: original.sha256 }] }) + write(context.temporary, 'small-source-manifest.json', smallSource) + const manifest = { ...context.mediaManifest, sourceManifestSha256: sha256(smallSource), entries: [original] } + assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory: source, + sourceManifestPath: path.join(context.temporary, 'small-source-manifest.json') }), /source bytes changed/) +}) + +test('missing manifest stays local; a verified remote import removes only owned output media and remains deterministic', t => { + const context = fixture(t, { remote: false }) + const missing = path.join(context.temporary, 'not-uploaded.json') + assert.equal(loadCosMediaManifest({ sourceDirectory, mediaManifestPath: missing }), null) + assert.equal(copyNativeProgram({ ...context.options, mediaManifest: undefined, mediaManifestPath: missing }).sizes.mediaFileCount, 204) + assert.equal(context.report.sizes.mediaFileCount, 204) + assert.equal(fs.existsSync(path.join(context.root, 'utils/cosMedia.js')), false) + const manifest = fakeVerifiedManifest() + write(context.temporary, 'verified-test-only.json', JSON.stringify(manifest)) + const options = { ...context.options, mediaManifest: undefined, mediaManifestPath: path.join(context.temporary, 'verified-test-only.json') } + const report = copyNativeProgram(options) + assert.equal(report.sizes.mediaFileCount, 0) + assert.equal(report.media.sourceMediaFiles, 204) + assert.equal(listFiles(context.root).filter(isMediaFile).length, 0) + const state = fs.readFileSync(path.join(context.root, '.native-import-state.json'), 'utf8') + copyNativeProgram(options) + assert.equal(fs.readFileSync(path.join(context.root, '.native-import-state.json'), 'utf8'), state) + assert.equal(fs.readFileSync(path.join(context.outputDirectory, 'app.js'), 'utf8'), '/* retained host entry */') + for (const file of sourceManifest.files) { + assert.equal(sha256(fs.readFileSync(path.join(sourceDirectory, file.path))), file.sha256, file.path) + } + for (const file of ['platformCore.js', 'storage.js', 'tangPage.js']) { + assert.deepEqual(fs.readFileSync(path.join(context.root, 'utils', file)), fs.readFileSync(path.join(overlayDirectory, 'utils', file)), file) + } +}) + +test('invalid manifest and externally edited old media are rejected before output mutation', t => { + const context = fixture(t, { remote: false }) + const before = fs.readFileSync(path.join(context.root, '.native-import-state.json')) + const invalid = fakeVerifiedManifest() + invalid.entries.pop() + assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: invalid }), /media coverage/) + assert.deepEqual(fs.readFileSync(path.join(context.root, '.native-import-state.json')), before) + const edited = sourceMedia.at(-1).path + write(context.root, edited, 'external user edit') + assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: fakeVerifiedManifest() }), /Stale native output has external edits/) + assert.equal(fs.readFileSync(path.join(context.root, edited), 'utf8'), 'external user edit') + assert.equal(sha256(fs.readFileSync(path.join(context.root, sourceMedia[0].path))), sourceMedia[0].sha256) +}) + +test('all 120 selected comic pages retain source content hashes and 112 formal / 8 provisional status', t => { + const context = fixture(t) + const season = context.require('data/season.js') + const originalSeason = sourceRequire(path.join(sourceDirectory, 'data/season.js')) + const originalPages = sourceRequire(path.join(sourceDirectory, 'package-game/pages/chapter/chapterPages.js')) + const media = context.require('utils/cosMedia.js') + const counts = { pages: 0, formal: 0, provisional: 0 } + for (let number = 1; number <= 15; number++) { + const packageRoot = number === 1 ? 'package-game' : `package-chapter-${String(number).padStart(2, '0')}` + const { buildComicPageModel } = context.require(`${packageRoot}/pages/chapter/chapterPages.js`) + const comic = context.require(`${packageRoot}/utils/comicPageModel.js`) + const { releaseAssets } = context.require(`${packageRoot}/data/releaseAssetManifest.js`) + const chapter = season.chapters[number - 1] + const original = originalPages.buildComicPageModel(originalSeason.chapters[number - 1], number) + const model = buildComicPageModel(chapter, number) + assert.equal(model.pageSequence.length, 8) + for (const [index, page] of model.pageSequence.entries()) { + const sourcePage = original.pageSequence[index] + const expected = sha256(fs.readFileSync(path.join(sourceDirectory, sourcePage.illustrationAsset))) + const assetId = `comic.${page.pageId.toLowerCase().replaceAll('-', '.')}` + const asset = releaseAssets[assetId] + const image = comic.buildComicImageState(page, asset, {}) + assert.equal(media.entryFor(image.src).sha256, expected, page.pageId) + assert.equal(image.src, page.illustrationAsset, page.pageId) + assert.equal(asset.remoteUrl, image.src) + assert.equal(asset.localSeed, '') + assert.match(image.src, /^https:\/\//) + assert.equal(image.source, 'remote-url') + assert.equal(page.playableVisual.formalReleaseEligible, sourcePage.playableVisual.formalReleaseEligible) + assert.equal(page.playableVisual.reviewStatus, sourcePage.playableVisual.reviewStatus) + assert.equal(media.entryFor(image.fallback).kind, 'image') + assert.equal(comic.isPackagedPath(image.src), false) + counts.pages++ + if (page.playableVisual.formalReleaseEligible) counts.formal++ + if (page.playableVisual.runtimeTier === 'experience-provisional') counts.provisional++ + } + } + assert.deepEqual(counts, { pages: 120, formal: 112, provisional: 8 }) + assert.equal(media.resolve('/tang-detective/assets/unknown.jpg'), '') + assert.equal(media.mapMedia({ image: '/tang-detective/assets/unknown.jpg' }).image, '') +}) + +test('source and adapter static images, cast, C01 player art and all eight listening tracks use exact registry URLs', t => { + const context = fixture(t) + const media = context.require('utils/cosMedia.js') + const cover = media.resolve('/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg') + assert.ok(fs.readFileSync(path.join(context.root, 'pages/home/home.wxml'), 'utf8').includes(`src="${cover}"`)) + for (const person of context.require('data/cast.js')) assert.equal(media.entryFor(person.asset).kind, 'image') + let count = 0 + for (const packageRoot of ['package-audio-c01-a', 'package-audio-c01-b']) { + const pages = context.require(`${packageRoot}/data/audioPages.js`) + let player + vm.runInNewContext(fs.readFileSync(path.join(context.root, packageRoot, 'pages/player/player.js'), 'utf8'), { + require(specifier) { + if (specifier.endsWith('/utils/tangPage.js')) return definition => { player = definition } + if (specifier === '../../data/audioPages') return pages + throw new Error(`Unexpected player dependency: ${specifier}`) + }, + }) + assert.equal(player.data.reviewStatus, 'technical-qa-pass-human-listening-pending') + for (const page of Object.values(pages)) { + assert.equal(page.reviewStatus, 'technical-qa-pass-human-listening-pending') + assert.equal(media.entryFor(page.audioSrc).sha256, page.sha256) + assert.equal(media.entryFor(page.imageSrc).kind, 'image') + let prepared = '' + player.onReady.call({ _page: page, _unloaded: false, createAudioContext: src => { prepared = src } }) + assert.equal(prepared, page.audioSrc) + count++ + } + } + assert.equal(count, 8) + const sharePage = fs.readFileSync(path.join(context.root, 'pages/share/share.js'), 'utf8') + assert.ok(sharePage.includes(media.resolve('/assets/share/guixiang-story-share-preview-v1.jpg'))) +}) + +test('remote transport does not open unreviewed audio, promote reserved cues or accept arbitrary HTTPS as approved', async t => { + const context = fixture(t) + const { manager, downloads, releaseAssets } = managerFixture(context) + const pages = context.require('package-game/data/remotePageAudioManifest.js') + const playerPages = context.require('package-audio-player/data/remotePageAudioManifest.js') + const comic = context.require('package-game/utils/comicPageModel.js') + assert.deepEqual(pages.remotePageAudioPages, {}) + assert.deepEqual(playerPages.remotePageAudioPages, {}) + for (let chapter = 2; chapter <= 15; chapter++) { + for (let page = 1; page <= 8; page++) { + const id = `S01-C${String(chapter).padStart(2, '0')}-P${String(page).padStart(2, '0')}` + assert.equal(pages.getApprovedRemotePageAudio(id, releaseAssets), null) + } + } + for (const id of ['audio.cast-audition.v1', 'audio.s01.c02.s01-c02-ms001', 'audio.s01.c03.s01-c03-ms001']) { + assert.equal((await manager.resolve(id)).reason, 'audio-unapproved') + } + const approvedId = 'audio.s01.c01.s01-c01-ms007' + assert.equal(releaseAssets[approvedId].reviewStatus, 'approved') + assert.equal(comic.getReviewedAudioSrc({ status: 'approved', assetId: approvedId, src: releaseAssets[approvedId].remoteUrl }, releaseAssets), '') + assert.equal(comic.getReviewedAudioSrc({ status: 'approved', assetId: approvedId, src: 'https://unknown.example.test/file.mp3' }, releaseAssets), '') + assert.equal(downloads.length, 0) +}) + +test('asset manager downloads exact URLs with an empty CDN base, verifies downloaded/cache bytes, and rejects substituted URLs', async t => { + const context = fixture(t) + const { manager, downloads, files, releaseAssets, managerApi, platform, responses } = managerFixture(context) + const id = 'comic.s01.c01.p01' + const first = await manager.resolve(id) + assert.equal(first.available, true) + assert.equal(first.persistent, true) + assert.equal(downloads[0], releaseAssets[id].remoteUrl) + assert.equal(sha256(files.get(first.uri)), releaseAssets[id].sha256) + assert.equal((await manager.resolve(id)).source, 'cache') + assert.equal(downloads.length, 1) + files.set(first.uri, Buffer.from('tampered cache')) + assert.equal((await manager.resolve(id)).reason, 'remote-integrity-failed') + assert.equal(files.has(first.uri), false) + const second = await manager.resolve(id) + assert.equal(second.available, true) + assert.equal(downloads.length, 2) + const replaced = { ...releaseAssets[id], remoteUrl: 'https://unknown.example.test/bad.jpg' } + const blocked = managerApi.createAssetManager({ manifest: { unregistered: replaced }, assetPlatform: platform }) + assert.equal((await blocked.resolve('unregistered')).reason, 'remote-integrity-failed') + assert.equal(downloads.length, 2) + const badId = 'comic.s01.c01.p03' + responses.set(releaseAssets[badId].remoteUrl, Buffer.from('corrupt download')) + assert.equal((await manager.resolve(badId)).reason, 'remote-integrity-failed') + assert.equal([...files.keys()].some(name => name.startsWith('/test-owned-temp/')), false) +}) + +test('share preview rehashes cache, fails safely on corruption/network errors, and ignores completion after unload', async t => { + const context = fixture(t) + const { manager, files, downloads, failDownloads } = managerFixture(context) + const media = context.require('utils/cosMedia.js') + const page = { data: {}, __tangShowGeneration: 1, __tangVisible: true, + setData(update) { Object.assign(this.data, update) } } + const first = await media.prepareSharePreview(page, manager) + assert.match(first, /^\/test-owned-user-data\//) + assert.equal(page.data.sharePreviewLocalPath, first) + const cached = await media.prepareSharePreview(page, manager) + assert.equal(cached, first) + assert.equal(downloads.length, 1) + files.set(first, Buffer.from('corrupted thumbnail')) + assert.equal(await media.prepareSharePreview(page, manager), '') + assert.equal(page.data.sharePreviewLocalPath, '') + failDownloads(true) + assert.equal(await media.prepareSharePreview(page, manager), '') + failDownloads(false) + assert.ok(await media.prepareSharePreview(page, manager)) + let finish + const delayed = { resolve: () => new Promise(resolve => { finish = resolve }) } + const pending = media.prepareSharePreview(page, delayed) + await Promise.resolve() + page.__tangDead = true + const prior = page.data.sharePreviewLocalPath + finish({ available: true, uri: '/should-not-be-shared' }) + assert.equal(await pending, '') + assert.equal(page.data.sharePreviewLocalPath, prior) + const chapterScript = fs.readFileSync(path.join(context.root, 'package-game/pages/chapter/chapter.js'), 'utf8') + assert.ok(chapterScript.includes('.prepareSharePreview(this, this.getAssetManager())')) + assert.ok(chapterScript.includes("const localPath = '' // Cached share bytes are revalidated")) + assert.ok(!chapterScript.includes('filePath: SHARE_PREVIEW_IMAGE')) +}) + +test('real-output static validator supports COS mode and rejects extra packaged media', t => { + const context = fixture(t) + const report = validateNativeOutput(context.outputDirectory, { mediaManifest: context.mediaManifest }) + assert.deepEqual(report.errors, []) + assert.equal(report.passed, true) + assert.equal(report.mediaFiles, 204) + assert.equal(report.packagedMediaFiles, 0) + assert.equal(report.mediaMode, 'cos') + write(context.root, 'assets/unowned-video.mp4', 'unexpected packaged video') + const invalid = validateNativeOutput(context.outputDirectory, { mediaManifest: context.mediaManifest }) + assert.equal(invalid.passed, false) + assert.ok(invalid.errors.includes('Unexpected native packaged media in COS mode')) +}) + +test('actual converted chapter image handler exhausts fallbacks and ignores duplicate/late events across page visits', t => { + const context = fixture(t) + let definition + const modules = new Map() + function load(filename) { + if (!path.extname(filename)) filename += '.js' + if (filename.endsWith('/utils/tangPage.js')) return options => { definition = options } + if (filename.endsWith('/utils/storage.js')) return {} + if (modules.has(filename)) return modules.get(filename).exports + const module = { exports: {} } + modules.set(filename, module) + vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module, + require: specifier => load(path.resolve(path.dirname(filename), specifier)), + }, { filename }) + return module.exports + } + load(path.join(context.root, 'package-game/pages/chapter/chapter.js')) + const chapter = context.require('data/season.js').chapters[0] + const model = context.require('package-game/pages/chapter/chapterPages.js').buildComicPageModel(chapter, 1) + let updates = 0 + const page = { ...definition, data: { ...definition.data, chapter, chapterNumber: 1 }, _comicModel: model, + setData(patch) { updates++; Object.assign(this.data, patch) } } + const reader = index => ({ valid: true, currentPageIndex: index, + currentPageId: model.pageSequence[index].pageId, completedEventIds: [], chapterFinished: false }) + const event = () => ({ currentTarget: { dataset: { + cosPageId: page.data.currentPageId, cosImageSrc: page.data.comicImageSrc, + cosImageGeneration: page.data.comicImageGeneration, + } } }) + page.applyComicReaderState(reader(2), false) + assert.ok(page.data.comicImageActorFallback) + assert.ok(page.data.comicImageFallback) + const originalImage = page.data.comicImageSrc + const originalPage = JSON.stringify(page.data.currentPage) + const interactionEnabled = page.data.currentInteractionEnabled + const firstEvent = event() + page.onComicImageError(firstEvent) + assert.equal(page.data.comicImageSource, 'actor-fallback') + assert.equal(page.data.comicImageSrc, page.data.comicImageActorFallback) + const actorUpdates = updates + page.onComicImageError(firstEvent) + assert.equal(updates, actorUpdates, 'duplicate primary failure must not skip the actor fallback') + const actorEvent = event() + page.onComicImageError(actorEvent) + assert.equal(page.data.comicImageSrc, page.data.comicImageFallback) + const sceneEvent = event() + page.onComicImageError(sceneEvent) + assert.equal(page.data.comicImageSrc, '') + assert.equal(page.data.comicImageSource, 'text-fallback') + assert.ok(page.data.comicImageError) + assert.equal(JSON.stringify(page.data.currentPage), originalPage) + assert.equal(page.data.currentInteractionEnabled, interactionEnabled) + const terminalUpdates = updates + for (let index = 0; index < 10; index++) page.onComicImageError(event()) + page.onComicImageLoad(sceneEvent) + assert.equal(updates, terminalUpdates, 'late events must leave terminal text intact') + page.applyComicReaderState(reader(2), false) + assert.equal(page.data.comicImageSrc, '', 'same-page interactions must not revive a failed URL') + page.applyComicReaderState(reader(3), false) + const nextImage = page.data.comicImageSrc + assert.ok(nextImage) + page.onComicImageError(firstEvent) + assert.equal(page.data.comicImageSrc, nextImage, 'previous-page event must be ignored') + page.applyComicReaderState(reader(2), false) + assert.equal(page.data.comicImageSrc, originalImage, 'revisiting a page starts a new attempt') + page.onComicImageError(firstEvent) + assert.equal(page.data.comicImageSrc, originalImage, 'same page ID from an earlier visit must be ignored') + page.onComicImageError(event()) + assert.equal(page.data.comicImageSrc, page.data.comicImageActorFallback) + const template = fs.readFileSync(path.join(context.root, 'package-game/pages/chapter/chapter.wxml'), 'utf8') + assert.equal((template.match(/data-cos-image-generation=/g) || []).length, + (template.match(/binderror="onComicImageError"/g) || []).length) +}) + +test('output preflight rejects root, file, directory and stale-media symlinks without touching identical external targets', t => { + const context = fixture(t, { remote: false }) + const snapshots = target => { + if (fs.lstatSync(target).isDirectory()) { + return listFiles(target).map(relative => [relative, sha256(fs.readFileSync(path.join(target, relative)))]) + } + return sha256(fs.readFileSync(target)) + } + const scenarios = [ + { target: context.outputDirectory }, + { target: context.root }, + { target: path.join(context.outputDirectory, 'app.json') }, + { target: path.join(context.root, '.native-import-state.json') }, + { target: path.join(context.root, 'utils/storage.js') }, + { target: path.join(context.root, 'utils') }, + { target: path.join(context.root, 'assets/scenes'), remote: true }, + { target: path.join(context.root, sourceMedia.at(-1).path), remote: true }, + { target: path.join(context.root, sourceMedia.at(-1).path), remote: true, dangling: true }, + ] + for (const [index, scenario] of scenarios.entries()) { + const external = path.join(context.temporary, `external-preserved-${index}`) + fs.renameSync(scenario.target, external) + const before = snapshots(external) + fs.symlinkSync(scenario.dangling ? `${external}-missing` : external, scenario.target) + try { + assert.throws(() => copyNativeProgram({ ...context.options, + mediaManifest: scenario.remote ? fakeVerifiedManifest() : null }), /Native output forbids symbolic links/) + assert.deepEqual(snapshots(external), before, scenario.target) + } finally { + fs.unlinkSync(scenario.target) + fs.renameSync(external, scenario.target) + } + // Even a cleanup conflict near the end must leave earlier media untouched. + assert.equal(sha256(fs.readFileSync(path.join(context.root, sourceMedia[0].path))), sourceMedia[0].sha256) + } + const externalNewFile = path.join(context.temporary, 'same-content-new-helper.js') + const newHelper = path.join(context.root, 'utils/cosMedia.js') + fs.copyFileSync(path.join(overlayDirectory, 'utils/cosMedia.js'), externalNewFile) + fs.symlinkSync(externalNewFile, newHelper) + const newHash = sha256(fs.readFileSync(externalNewFile)) + try { + assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: fakeVerifiedManifest() }), /Native output forbids symbolic links/) + assert.equal(sha256(fs.readFileSync(externalNewFile)), newHash) + } finally { fs.unlinkSync(newHelper) } + const alias = path.join(context.temporary, 'ancestor-alias') + fs.symlinkSync(context.temporary, alias, 'dir') + assert.equal(copyNativeProgram({ ...context.options, outputDirectory: path.join(alias, 'output') }).sizes.mediaFileCount, 204) + const local = validateNativeOutput(context.outputDirectory, { mediaManifest: null }) + assert.deepEqual(local.errors, []) + assert.equal(local.passed, true) + assert.equal(local.mediaMode, 'local') + assert.equal(local.packagedMediaFiles, 204) + assert.ok(local.relativeDependencies > 300) +}) diff --git a/TUICallKit-Vue3/build/tang-detective-native-plugin.mjs b/TUICallKit-Vue3/build/tang-detective-native-plugin.mjs new file mode 100644 index 0000000..adbfc00 --- /dev/null +++ b/TUICallKit-Vue3/build/tang-detective-native-plugin.mjs @@ -0,0 +1,376 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { applyCosMediaOutput, DEFAULT_MEDIA_MANIFEST_PATH, isMediaFile, loadCosMediaManifest, SOURCE_MANIFEST_PATH } from './tang-detective-cos-media.mjs' + +export const NAMESPACE = 'tang-detective' +const TEXT_EXTENSIONS = new Set(['.js', '.json', '.wxml', '.wxss', '.wxs']) +const PAGE_WINDOW_KEYS = ['navigationStyle', 'pageOrientation', 'backgroundColor', 'backgroundTextStyle'] +const STATE_FILE = '.native-import-state.json' +const BOOT_MASK_WXML = '正在读取阅读存档…' +const BOOT_MASK_WXSS = ` +.tang-boot-mask { + position: fixed; + inset: 0; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 2147483647; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: 24px; + background: #201711; + color: #f3e5bd; + font-size: 18px; + text-align: center; +} +` + +export function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex') +} + +export function listFiles(directory) { + if (!fs.existsSync(directory)) return [] + const result = [] + function visit(current, prefix = '') { + for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isSymbolicLink()) throw new Error(`Native import does not follow symbolic links: ${relative}`) + if (entry.isDirectory()) visit(path.join(current, entry.name), relative) + else if (entry.isFile()) result.push(relative) + } + } + visit(directory) + return result +} + +function assertNamespace(namespace) { + if (!/^[a-z][a-z0-9-]*$/.test(namespace)) throw new Error(`Invalid native namespace: ${namespace}`) +} + +function outputPath(directory, relative) { + if (typeof relative !== 'string' || !relative || path.isAbsolute(relative) + || relative.includes('\\') || relative.split('/').some(part => !part || part === '.' || part === '..')) { + throw new Error(`Unsafe native output path: ${relative}`) + } + return path.join(directory, relative) +} + +function lstatOrNull(filename) { + try { return fs.lstatSync(filename) } catch (error) { + if (error.code === 'ENOENT') return null + throw error + } +} + +/** Ancestors such as macOS /tmp may be aliases, but the supplied output root + * itself and every component below it must be actual directories/files. */ +export function createNativeOutputGuard(directory) { + const suppliedRoot = path.resolve(directory) + const suppliedStat = lstatOrNull(suppliedRoot) + if (suppliedStat?.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${suppliedRoot}`) + if (!suppliedStat?.isDirectory()) throw new Error(`Native output root must be an existing directory: ${suppliedRoot}`) + const root = fs.realpathSync(suppliedRoot) + function check(relative, expectedType = 'file') { + const rootStat = lstatOrNull(root) + if (rootStat?.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${root}`) + if (!rootStat?.isDirectory() || fs.realpathSync(root) !== root) throw new Error(`Native output root changed: ${root}`) + const destination = outputPath(root, relative) + const parts = relative.split('/') + let current = root + for (const [index, part] of parts.entries()) { + current = path.join(current, part) + const stat = lstatOrNull(current) + if (!stat) break + if (stat.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${current}`) + const real = fs.realpathSync(current) + const inside = path.relative(root, real) + if (path.isAbsolute(inside) || inside === '..' || inside.startsWith(`..${path.sep}`)) { + throw new Error(`Native output escaped its real directory: ${current}`) + } + const needsDirectory = index < parts.length - 1 || expectedType === 'directory' + if (needsDirectory ? !stat.isDirectory() : !stat.isFile()) { + throw new Error(`Unexpected native output path type: ${current}`) + } + } + return destination + } + function write(relative, data) { + const destination = check(relative) + fs.mkdirSync(path.dirname(destination), { recursive: true }) + check(relative) + // Re-check parent components after mkdir and prevent following a replaced + // final-file link between lstat and opening the destination. + const descriptor = fs.openSync(destination, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW) + try { fs.writeFileSync(descriptor, data) } finally { fs.closeSync(descriptor) } + } + return { root, path: check, write } +} + +function jsonBytes(value) { + return `${JSON.stringify(value, null, 2)}\n` +} + +/** Transform only the imported copy. Remote URLs, hashes, and relative requires stay intact. */ +export function transformNativeText(source, relativePath, { namespace = NAMESPACE } = {}) { + assertNamespace(namespace) + if (!TEXT_EXTENSIONS.has(path.extname(relativePath))) return source + return source + .replace(/(["'`])\/(?=(?:assets|pages|package-[a-z0-9-]+)\/)/g, `$1/${namespace}/`) + .replace(/(["'`])(?=package-(?:game|chapter-|audio-))/g, `$1${namespace}/`) + .replaceAll( + String.raw`/^\/package-[a-z0-9-]+\//i`, + String.raw`/^\/${namespace}\/package-[a-z0-9-]+\//i`, + ) +} + +export function createNativeManifest(sourceApp, { namespace = NAMESPACE } = {}) { + assertNamespace(namespace) + const sourcePackages = sourceApp.subPackages || sourceApp.subpackages || [] + const packageNames = new Map(sourcePackages.map(item => [item.name || item.root, `${namespace}-${item.name || item.root}`])) + const pages = (sourceApp.pages || []).map(item => `${namespace}/${item}`) + const subPackages = sourcePackages.map(item => ({ + ...item, + root: `${namespace}/${item.root}`, + name: packageNames.get(item.name || item.root), + pages: [...item.pages], + })) + const preloadRule = Object.fromEntries(Object.entries(sourceApp.preloadRule || {}).map(([page, rule]) => [ + `${namespace}/${page}`, + { ...rule, packages: rule.packages.map(name => { + const mapped = packageNames.get(name) + || subPackages.find(item => item.root === `${namespace}/${name}`)?.name + if (!mapped) throw new Error(`Unregistered native preload package: ${name}`) + return mapped + }) }, + ])) + const allPages = [...pages, ...subPackages.flatMap(item => item.pages.map(page => `${item.root}/${page}`))] + if (new Set(allPages).size !== allPages.length) throw new Error('Duplicate native page route') + return { namespace, pages, subPackages, preloadRule, allPages } +} + +/** Append native routes without replacing the host's app settings or route ordering. */ +export function mergeNativeAppManifest(hostApp, nativeManifest) { + const result = structuredClone(hostApp) + const packageKey = Object.hasOwn(hostApp, 'subpackages') && !Object.hasOwn(hostApp, 'subPackages') + ? 'subpackages' : 'subPackages' + const hostPages = result.pages || [] + const hostPackages = result[packageKey] || [] + const nativePages = new Set(nativeManifest.allPages) + for (const item of hostPackages) { + const proposed = nativeManifest.subPackages.find(candidate => candidate.root === item.root) + if (proposed) { + if (JSON.stringify(item) !== JSON.stringify(proposed)) throw new Error(`Native subpackage conflicts with host: ${item.root}`) + continue + } + if ((item.pages || []).some(page => nativePages.has(`${item.root}/${page}`))) { + throw new Error(`Native page conflicts with host subpackage: ${item.root}`) + } + if (nativeManifest.subPackages.some(candidate => candidate.name === item.name)) { + throw new Error(`Native subpackage name conflicts with host: ${item.name}`) + } + } + for (const page of hostPages) { + if (nativePages.has(page) && !nativeManifest.pages.includes(page)) { + throw new Error(`Native subpackage page is already a host main page: ${page}`) + } + } + result.pages = [...hostPages, ...nativeManifest.pages.filter(page => !hostPages.includes(page))] + result[packageKey] = [...hostPackages, ...nativeManifest.subPackages.filter(item => !hostPackages.some(existing => existing.root === item.root))] + const preloadRule = { ...(result.preloadRule || {}) } + for (const [page, rule] of Object.entries(nativeManifest.preloadRule)) { + if (preloadRule[page] && JSON.stringify(preloadRule[page]) !== JSON.stringify(rule)) { + throw new Error(`Native preload conflicts with host: ${page}`) + } + preloadRule[page] = rule + } + result.preloadRule = preloadRule + return result +} + +export function wrapNativePage(source, relativePath) { + const wrapperPath = path.posix.relative(path.posix.dirname(relativePath), 'utils/tangPage.js') + const topLevelCalls = [...source.matchAll(/^Page\(\{/gm)] + if (topLevelCalls.length !== 1) throw new Error(`Expected one top-level native Page registration: ${relativePath}`) + return source.replace(/^Page\(\{/m, `require(${JSON.stringify(wrapperPath)})({`) +} + +function collectOutputFiles(sourceDirectory, overlayDirectory, sourceApp, nativeManifest, apiBaseUrl, media) { + const files = new Map() + const sourcePages = new Set(nativeManifest.allPages.map(page => page.slice(nativeManifest.namespace.length + 1))) + const pageDefaults = Object.fromEntries(PAGE_WINDOW_KEYS + .filter(key => sourceApp.window?.[key] !== undefined) + .map(key => [key, sourceApp.window[key]])) + for (const relative of listFiles(sourceDirectory)) { + if (['app.js', 'app.json', 'sitemap.json'].includes(relative)) continue + const original = fs.readFileSync(outputPath(sourceDirectory, relative)) + if (relative === 'app.wxss') { + files.set('shared.wxss', original) + continue + } + const extension = path.extname(relative) + if (!TEXT_EXTENSIONS.has(extension)) { + files.set(relative, original) + continue + } + let content = transformNativeText(original.toString('utf8'), relative, nativeManifest) + const isPage = sourcePages.has(relative.slice(0, -extension.length)) + if (isPage && extension === '.json') content = jsonBytes({ ...pageDefaults, ...JSON.parse(content) }) + if (isPage && extension === '.wxss') { + content = `@import "/${nativeManifest.namespace}/shared.wxss";\n${content}` + } + files.set(relative, Buffer.from(content)) + } + // Adapters contain final output paths. Do not transform them a second time. + for (const relative of listFiles(overlayDirectory)) { + // Remote helpers are activated together with a verified manifest only. + if (relative === 'utils/cosMedia.js') continue + if (['app.js', 'app.json', 'app.wxss', 'sitemap.json', STATE_FILE].includes(relative) + || /(^|\/)project(?:\.private)?\.config\.json$/.test(relative)) { + throw new Error(`Adapter cannot replace the host app or import state: ${relative}`) + } + if (isMediaFile(relative)) { + throw new Error(`Adapter cannot replace source media: ${relative}`) + } + files.set(relative, fs.readFileSync(outputPath(overlayDirectory, relative))) + } + files.set('utils/platformConfig.js', Buffer.from(`module.exports = ${JSON.stringify({ apiBaseUrl })};\n`)) + // Media conversion also covers the adapter's final paths, without repeating + // namespace conversion or changing the host/account lifecycle adapters. + applyCosMediaOutput(files, media, nativeManifest) + if (!files.has('utils/tangPage.js')) throw new Error('Native adapter is missing utils/tangPage.js') + files.set('shared.wxss', Buffer.from(`${files.get('shared.wxss').toString('utf8')}\n${BOOT_MASK_WXSS}`)) + for (const page of sourcePages) { + for (const extension of ['.js', '.json', '.wxml', '.wxss']) { + if (!files.has(`${page}${extension}`)) throw new Error(`Native page file is missing: ${page}${extension}`) + } + const pageJson = JSON.parse(files.get(`${page}.json`).toString('utf8')) + if (pageJson.navigationStyle !== 'custom' || pageJson.pageOrientation !== 'landscape') { + throw new Error(`Adapter lost native page configuration: ${page}`) + } + files.set(`${page}.js`, Buffer.from(wrapNativePage(files.get(`${page}.js`).toString('utf8'), `${page}.js`))) + files.set(`${page}.wxml`, Buffer.from(`${files.get(`${page}.wxml`).toString('utf8')}\n${BOOT_MASK_WXML}\n`)) + } + return files +} + +export function summarizePackageSizes(files, nativeManifest) { + const result = { sourceFileBytes: 0, mainPackageBytes: 0, subPackages: {}, mediaFileCount: 0, mediaBytes: 0 } + for (const [relative, value] of files) { + const fullPath = `${nativeManifest.namespace}/${relative}` + const subpackage = nativeManifest.subPackages.find(item => fullPath.startsWith(`${item.root}/`)) + result.sourceFileBytes += value.length + if (subpackage) result.subPackages[subpackage.root] = (result.subPackages[subpackage.root] || 0) + value.length + else result.mainPackageBytes += value.length + if (isMediaFile(relative)) { + result.mediaFileCount += 1 + result.mediaBytes += value.length + } + } + return result +} + +/** Reusable by the Vite hook and by static tests; no build or server is started here. */ +export function copyNativeProgram({ sourceDirectory, outputDirectory, overlayDirectory, namespace = NAMESPACE, apiBaseUrl = '', mediaManifest, mediaManifestPath }) { + assertNamespace(namespace) + const outputGuard = createNativeOutputGuard(outputDirectory) + outputDirectory = outputGuard.root + const media = loadCosMediaManifest({ sourceDirectory, mediaManifest, mediaManifestPath }) + const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceDirectory, 'app.json'), 'utf8')) + const hostAppPath = outputGuard.path('app.json') + const hostApp = JSON.parse(fs.readFileSync(hostAppPath, 'utf8')) + const nativeManifest = createNativeManifest(sourceApp, { namespace }) + const mergedApp = mergeNativeAppManifest(hostApp, nativeManifest) + const files = collectOutputFiles(sourceDirectory, overlayDirectory, sourceApp, nativeManifest, apiBaseUrl, media) + const nativeOutputDirectory = outputGuard.path(namespace, 'directory') + const nativeTarget = relative => { + // Validate the untrusted old-state relative path before adding namespace. + const target = outputPath(nativeOutputDirectory, relative) + return outputGuard.path(path.relative(outputDirectory, target)) + } + const statePath = nativeTarget(STATE_FILE) + const previousState = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, 'utf8')) : null + if (previousState && previousState.namespace !== namespace) throw new Error('Native output ownership mismatch') + const previousFiles = new Map((previousState?.files || []).map(item => [item.path, item.sha256])) + // Refuse to overwrite files whose ownership or later edits cannot be established. + for (const [relative, value] of files) { + const destination = nativeTarget(relative) + if (!fs.existsSync(destination)) continue + const existingHash = sha256(fs.readFileSync(destination)) + if (existingHash !== previousFiles.get(relative) && existingHash !== sha256(value)) { + throw new Error(`Refusing to overwrite unowned native output: ${relative}`) + } + } + const staleFiles = [] + for (const [relative, expectedHash] of previousFiles) { + if (files.has(relative)) continue + const stalePath = nativeTarget(relative) + if (fs.existsSync(stalePath)) { + if (sha256(fs.readFileSync(stalePath)) !== expectedHash) throw new Error(`Stale native output has external edits: ${relative}`) + staleFiles.push(relative) + } + } + // Check every stale file before removing any: a conflict late in the media + // list must not leave a previously usable local build partially stripped. + for (const relative of staleFiles) fs.unlinkSync(nativeTarget(relative)) + for (const [relative, value] of files) outputGuard.write(`${namespace}/${relative}`, value) + const state = { + version: 1, + namespace, + pages: nativeManifest.allPages, + files: [...files].map(([relative, value]) => ({ path: relative, bytes: value.length, sha256: sha256(value) })), + sizes: summarizePackageSizes(files, nativeManifest), + ...(media ? { media: { mode: 'cos', sourceManifestSha256: media.sourceManifestSha256, + manifestSha256: media.manifestSha256, sourceMediaFiles: media.entries.size, objectCount: media.objectCount } } : {}), + validationBoundary: 'Static copy and route integration only; no device, upload, content review, or release approval.', + } + outputGuard.write(`${namespace}/${STATE_FILE}`, jsonBytes(state)) + outputGuard.write('app.json', jsonBytes(mergedApp)) + return { ...state, nativeManifest } +} + +export default function tangDetectiveNativePlugin(options = {}) { + let root + let buildOutput + const sourceRelative = options.sourceDirectory || 'native/tang-detective' + const overlayRelative = options.overlayDirectory || 'native-adapter/tang-detective' + return { + name: 'tang-detective-native-pages', + enforce: 'post', + apply: () => process.env.UNI_PLATFORM === 'mp-weixin', + configResolved(config) { + root = config.root + buildOutput = path.resolve(root, config.build.outDir) + }, + buildStart() { + this.addWatchFile(options.mediaManifestPath || DEFAULT_MEDIA_MANIFEST_PATH) + this.addWatchFile(SOURCE_MANIFEST_PATH) + for (const directory of [path.resolve(root, sourceRelative), path.resolve(root, overlayRelative)]) { + this.addWatchFile(directory) + for (const relative of listFiles(directory)) this.addWatchFile(path.join(directory, relative)) + } + }, + // Sequential post-order runs after normal write hooks and is repeated for watch rebuilds. + writeBundle: { + order: 'post', + sequential: true, + handler(outputOptions) { + const report = copyNativeProgram({ + sourceDirectory: path.resolve(root, sourceRelative), + overlayDirectory: path.resolve(root, overlayRelative), + outputDirectory: outputOptions.dir ? path.resolve(root, outputOptions.dir) : buildOutput, + namespace: options.namespace || NAMESPACE, + apiBaseUrl: options.apiBaseUrl || '', + mediaManifest: options.mediaManifest, + mediaManifestPath: options.mediaManifestPath, + }) + this.warn(`唐侦探原生页面已合并:${report.pages.length} 页,原生文件 ${report.sizes.sourceFileBytes} bytes,其中主包新增 ${report.sizes.mainPackageBytes} bytes;此结果不代表包体积或发布验收通过。`) + }, + }, + } +} diff --git a/TUICallKit-Vue3/build/tang-detective-native-plugin.test.mjs b/TUICallKit-Vue3/build/tang-detective-native-plugin.test.mjs new file mode 100644 index 0000000..ba72342 --- /dev/null +++ b/TUICallKit-Vue3/build/tang-detective-native-plugin.test.mjs @@ -0,0 +1,262 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import vm from 'node:vm' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import nativePlugin, { + copyNativeProgram, + createNativeManifest, + listFiles, + mergeNativeAppManifest, + sha256, + transformNativeText, + wrapNativePage, +} from './tang-detective-native-plugin.mjs' + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const sourceDirectory = path.join(projectRoot, 'native/tang-detective') +const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceDirectory, 'app.json'), 'utf8')) +const mediaPattern = /\.(?:jpg|jpeg|png|webp|mp3|wav|aac|m4a|ogg)$/i + +function write(directory, relative, content) { + const filename = path.join(directory, relative) + fs.mkdirSync(path.dirname(filename), { recursive: true }) + fs.writeFileSync(filename, content) +} + +function fixture(t) { + const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tang-native-test-'))) + // This directory is created and exclusively owned by this individual test. + t.after(() => fs.rmSync(temporary, { recursive: true, force: true })) + const outputDirectory = path.join(temporary, 'output') + const overlayDirectory = path.join(temporary, 'overlay') + const hostApp = { + pages: ['pages/index/index'], + subPackages: [{ root: 'tongji', pages: ['endless-game/index'] }], + window: { navigationStyle: 'default', pageOrientation: 'portrait', backgroundColor: '#ffffff' }, + tabBar: { list: [{ pagePath: 'pages/index/index', text: '首页' }] }, + permission: { 'scope.record': { desc: '通话' } }, + preloadRule: { 'pages/index/index': { network: 'wifi', packages: ['tongji'] } }, + } + write(outputDirectory, 'app.json', JSON.stringify(hostApp)) + write(outputDirectory, 'app.js', '/* host App entry must stay unchanged */') + write(outputDirectory, 'app.wxss', '/* host global styles must stay unchanged */') + write(outputDirectory, 'project.config.json', '{"description":"host project"}') + write(outputDirectory, 'pages/index/index.js', '/* host home page */') + write(outputDirectory, 'tongji/endless-game/index.js', '/* existing game */') + write(overlayDirectory, 'utils/tangPage.js', 'module.exports = function (definition) { return Page(definition) }\n') + const options = { sourceDirectory, outputDirectory, overlayDirectory, apiBaseUrl: 'https://api.example.test', mediaManifest: null } + return { ...options, options, hostApp } +} + +function packageOwner(relative, manifest) { + return manifest.subPackages.find(item => relative.startsWith(`${item.root}/`))?.root || 'main' +} + +test('imported snapshot matches every recorded source hash and preserves all 204 media files', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, 'build/tang-detective-source-manifest.json'), 'utf8')) + assert.equal(manifest.files.length, 473) + assert.equal(listFiles(sourceDirectory).length, manifest.files.length) + assert.equal(manifest.files.filter(item => mediaPattern.test(item.path)).length, 204) + for (const file of manifest.files) { + const bytes = fs.readFileSync(path.join(sourceDirectory, file.path)) + assert.equal(bytes.length, file.bytes, file.path) + assert.equal(sha256(bytes), file.sha256, file.path) + } + assert.equal(fs.existsSync(path.join(sourceDirectory, 'app.js')), false) +}) + +test('path conversion includes dynamic roots and packaged-path regex without changing external URLs or requires', () => { + const source = [ + "const a = '/pages/share/share?x=1'", + 'const b = "/assets/share/card.jpg"', + "const c = 'package-game'", + 'const d = `package-chapter-${number}`', + 'const e = `/${packageRoot}/assets/comic/page.jpg`', + "const f = require('../../../utils/storage')", + "const remote = 'https://cdn.example.test/assets/share/card.jpg'", + String.raw`const valid = /^\/package-[a-z0-9-]+\//i`, + ].join('\n') + const result = transformNativeText(source, 'example.js') + assert.match(result, /'\/tang-detective\/pages\/share\/share\?x=1'/) + assert.match(result, /"\/tang-detective\/assets\/share\/card.jpg"/) + assert.match(result, /'tang-detective\/package-game'/) + assert.ok(result.includes('`tang-detective/package-chapter-${number}`')) + assert.ok(result.includes('`/${packageRoot}/assets/comic/page.jpg`')) + assert.ok(result.includes("require('../../../utils/storage')")) + assert.ok(result.includes("'https://cdn.example.test/assets/share/card.jpg'")) + assert.ok(result.includes(String.raw`/^\/tang-detective\/package-[a-z0-9-]+\//i`)) + assert.equal(transformNativeText(result, 'example.js'), result) + new vm.Script(result) +}) + +test('manifest merge preserves host configuration, supports both package spellings, and rejects conflicting routes', () => { + const manifest = createNativeManifest(sourceApp) + assert.equal(manifest.pages.length, 6) + assert.equal(manifest.subPackages.length, 18) + assert.equal(manifest.allPages.length, 24) + const host = { pages: ['pages/index/index'], subpackages: [{ root: 'tongji', pages: ['pages/index'] }], window: { pageOrientation: 'portrait' } } + const merged = mergeNativeAppManifest(host, manifest) + assert.deepEqual(host, { pages: ['pages/index/index'], subpackages: [{ root: 'tongji', pages: ['pages/index'] }], window: { pageOrientation: 'portrait' } }) + assert.deepEqual(merged.window, host.window) + assert.equal(merged.subPackages, undefined) + assert.deepEqual(mergeNativeAppManifest(merged, manifest), merged) + assert.deepEqual(merged.preloadRule['tang-detective/package-game/pages/chapter/chapter'].packages, ['tang-detective-audio-c01-a']) + assert.throws(() => mergeNativeAppManifest({ pages: [manifest.subPackages[0].root + '/pages/chapter/chapter'] }, manifest), /already a host main page/) + assert.throws(() => mergeNativeAppManifest({ subPackages: [{ root: manifest.subPackages[0].root, pages: ['wrong'] }] }, manifest), /conflicts with host/) +}) + +test('all native pages copy into owned output with legal relative requires and unchanged host/media bytes', t => { + const context = fixture(t) + const guardedFiles = ['app.js', 'app.wxss', 'project.config.json', 'pages/index/index.js', 'tongji/endless-game/index.js'] + const before = new Map(guardedFiles.map(file => [file, sha256(fs.readFileSync(path.join(context.outputDirectory, file)))])) + // The final adapter path must be copied literally, without a second prefix pass. + const overlay = "module.exports = { home: '/tang-detective/pages/home/home', host: '/pages/index/index' }\n" + write(context.overlayDirectory, 'utils/overlayProbe.js', overlay) + // Final safety UI is added after adapters, including a fully replaced home template. + const homeTemplate = 'adapted home' + write(context.overlayDirectory, 'pages/home/home.wxml', homeTemplate) + const report = copyNativeProgram(context.options) + assert.equal(report.pages.length, 24) + assert.equal(report.sizes.mediaFileCount, 204) + const nativeDirectory = path.join(context.outputDirectory, 'tang-detective') + assert.equal(fs.readFileSync(path.join(nativeDirectory, 'utils/overlayProbe.js'), 'utf8'), overlay) + assert.ok(fs.readFileSync(path.join(nativeDirectory, 'pages/home/home.wxml'), 'utf8').startsWith(homeTemplate)) + const sharedStyle = fs.readFileSync(path.join(nativeDirectory, 'shared.wxss'), 'utf8') + assert.equal((sharedStyle.match(/\.tang-boot-mask\s*\{/g) || []).length, 1) + const maskStyle = sharedStyle.slice(sharedStyle.indexOf('.tang-boot-mask')) + for (const declaration of ['position: fixed', 'inset: 0', 'z-index: 2147483647', 'background: #201711', 'color: #f3e5bd', 'font-size: 18px', 'align-items: center', 'justify-content: center']) { + assert.ok(maskStyle.includes(declaration), declaration) + } + const localRequire = createRequire(path.join(context.outputDirectory, 'package.cjs')) + assert.deepEqual(localRequire(path.join(nativeDirectory, 'utils/platformConfig.js')), { apiBaseUrl: context.apiBaseUrl }) + for (const file of guardedFiles) assert.equal(sha256(fs.readFileSync(path.join(context.outputDirectory, file))), before.get(file), file) + const merged = JSON.parse(fs.readFileSync(path.join(context.outputDirectory, 'app.json'), 'utf8')) + for (const key of ['window', 'tabBar', 'permission']) assert.deepEqual(merged[key], context.hostApp[key]) + assert.deepEqual(merged.preloadRule['pages/index/index'], context.hostApp.preloadRule['pages/index/index']) + for (const relative of listFiles(sourceDirectory).filter(file => mediaPattern.test(file))) { + assert.equal(sha256(fs.readFileSync(path.join(nativeDirectory, relative))), sha256(fs.readFileSync(path.join(sourceDirectory, relative))), relative) + } + for (const page of report.pages) { + const config = JSON.parse(fs.readFileSync(path.join(context.outputDirectory, `${page}.json`), 'utf8')) + assert.equal(config.navigationStyle, 'custom', page) + assert.equal(config.pageOrientation, 'landscape', page) + const style = fs.readFileSync(path.join(context.outputDirectory, `${page}.wxss`), 'utf8') + assert.ok(style.startsWith('@import "/tang-detective/shared.wxss";'), page) + const script = fs.readFileSync(path.join(context.outputDirectory, `${page}.js`), 'utf8') + assert.doesNotMatch(script, /^Page\(\{/m, page) + assert.match(script, /require\("\.\.\/(?:\.\.\/)*utils\/tangPage\.js"\)\(\{/, page) + const template = fs.readFileSync(path.join(context.outputDirectory, `${page}.wxml`), 'utf8') + assert.equal((template.match(/class="tang-boot-mask"/g) || []).length, 1, page) + assert.ok(template.trimEnd().endsWith('正在读取阅读存档…'), page) + } + let dependencyCount = 0 + for (const relative of listFiles(nativeDirectory).filter(file => file.endsWith('.js'))) { + const filename = path.join(nativeDirectory, relative) + const script = fs.readFileSync(filename, 'utf8') + new vm.Script(script, { filename: relative }) + for (const [, specifier] of script.matchAll(/require\(['"]([^'"]+)['"]\)/g)) { + assert.ok(specifier.startsWith('.'), `${relative}: native dependencies must be relative (${specifier})`) + const resolved = localRequire.resolve(path.resolve(path.dirname(filename), specifier)) + const resolvedRelative = path.relative(context.outputDirectory, resolved).split(path.sep).join('/') + assert.ok(resolvedRelative.startsWith('tang-detective/'), `${relative}: dependency escaped namespace`) + const caller = packageOwner(`tang-detective/${relative}`, report.nativeManifest) + const dependency = packageOwner(resolvedRelative, report.nativeManifest) + assert.ok(dependency === 'main' || caller === dependency, `${relative} imports a sibling subpackage: ${specifier}`) + dependencyCount += 1 + } + } + assert.ok(dependencyCount > 300) + assert.equal(fs.existsSync(path.join(nativeDirectory, 'app.js')), false) + assert.equal(fs.existsSync(path.join(nativeDirectory, 'app.json')), false) + assert.equal(fs.existsSync(path.join(nativeDirectory, 'sitemap.json')), false) + const firstState = fs.readFileSync(path.join(nativeDirectory, '.native-import-state.json'), 'utf8') + copyNativeProgram(context.options) + assert.equal(fs.readFileSync(path.join(nativeDirectory, '.native-import-state.json'), 'utf8'), firstState) + assert.equal(JSON.parse(fs.readFileSync(path.join(context.outputDirectory, 'app.json'), 'utf8')).pages.length, 7) +}) + +test('120 chapter page image selections and C01 full tracks remain in main or their own subpackage', t => { + const context = fixture(t) + const report = copyNativeProgram(context.options) + const localRequire = createRequire(path.join(context.outputDirectory, 'package.cjs')) + const root = path.join(context.outputDirectory, 'tang-detective') + const season = localRequire(path.join(root, 'data/season.js')) + const routing = localRequire(path.join(root, 'utils/chapterRoute.js')) + function assertLocalAsset(asset, owner) { + if (!asset) return + assert.ok(asset.startsWith('/tang-detective/'), asset) + const relative = asset.slice(1) + const targetOwner = packageOwner(relative, report.nativeManifest) + assert.ok(targetOwner === 'main' || targetOwner === owner, `Illegal sibling asset read: ${owner} -> ${asset}`) + assert.ok(fs.existsSync(path.join(context.outputDirectory, relative)), `Missing selected asset: ${asset}`) + } + let pages = 0 + for (let number = 1; number <= 15; number += 1) { + const packageRoot = routing.chapterPackageRoot(number) + assert.ok(report.pages.includes(routing.chapterRoute(number).split('?')[0].slice(1))) + const chapter = season.chapters.find(item => item.chapterNumber === number) + const chapterPages = localRequire(path.join(context.outputDirectory, packageRoot, 'pages/chapter/chapterPages.js')) + const modelUtils = localRequire(path.join(context.outputDirectory, packageRoot, 'utils/comicPageModel.js')) + const { releaseAssets } = localRequire(path.join(context.outputDirectory, packageRoot, 'data/releaseAssetManifest.js')) + assert.equal(modelUtils.isPackagedPath(`/${packageRoot}/assets/example.jpg`), true) + assert.equal(modelUtils.isPackagedPath('/package-game/assets/example.jpg'), false) + const model = chapterPages.buildComicPageModel(chapter, number) + assert.equal(model.pageSequence.length, 8) + for (const page of model.pageSequence) { + const [, seasonNumber, chapterNumber, pageNumber] = page.pageId.match(/^S(\d+)-C(\d+)-P(\d+)$/) + const asset = releaseAssets[`comic.s${seasonNumber}.c${chapterNumber}.p${pageNumber}`] + const image = modelUtils.buildComicImageState(page, asset, {}) + assertLocalAsset(image.src, packageRoot) + assertLocalAsset(image.fallback, packageRoot) + pages += 1 + } + } + assert.equal(pages, 120) + for (const audioRoot of ['tang-detective/package-audio-c01-a', 'tang-detective/package-audio-c01-b']) { + const tracks = localRequire(path.join(context.outputDirectory, audioRoot, 'data/audioPages.js')) + for (const track of Object.values(tracks)) { + assertLocalAsset(track.audioSrc, audioRoot) + assertLocalAsset(track.imageSrc, audioRoot) + assert.equal(track.reviewStatus, 'technical-qa-pass-human-listening-pending') + } + } +}) + +test('overlay protection and owned cleanup preserve media, host state, and external edits', t => { + const context = fixture(t) + write(context.overlayDirectory, 'utils/obsolete.js', 'module.exports = 1') + copyNativeProgram(context.options) + fs.unlinkSync(path.join(context.overlayDirectory, 'utils/obsolete.js')) + copyNativeProgram(context.options) + assert.equal(fs.existsSync(path.join(context.outputDirectory, 'tang-detective/utils/obsolete.js')), false) + write(context.outputDirectory, 'tang-detective/utils/storage.js', '/* external user edit */') + assert.throws(() => copyNativeProgram(context.options), /unowned native output/) + write(context.overlayDirectory, 'app.js', 'App({})') + assert.throws(() => copyNativeProgram(context.options), /cannot replace the host app/) + fs.unlinkSync(path.join(context.overlayDirectory, 'app.js')) + write(context.overlayDirectory, 'assets/replacement.jpg', 'not an allowed media replacement') + assert.throws(() => copyNativeProgram(context.options), /cannot replace source media/) +}) + +test('page registration rejects ambiguous input and Vite hook remains post-sequential and WeChat-only', () => { + assert.equal(wrapNativePage('Page({\n})', 'pages/home/home.js'), 'require("../../utils/tangPage.js")({\n})') + assert.throws(() => wrapNativePage('Page({})\nPage({})', 'pages/home/home.js'), /one top-level/) + const plugin = nativePlugin() + assert.equal(plugin.enforce, 'post') + assert.equal(plugin.writeBundle.order, 'post') + assert.equal(plugin.writeBundle.sequential, true) + const previous = process.env.UNI_PLATFORM + try { + process.env.UNI_PLATFORM = 'h5' + assert.equal(plugin.apply(), false) + process.env.UNI_PLATFORM = 'mp-weixin' + assert.equal(plugin.apply(), true) + } finally { + if (previous === undefined) delete process.env.UNI_PLATFORM + else process.env.UNI_PLATFORM = previous + } +}) diff --git a/TUICallKit-Vue3/build/tang-detective-native-validation.json b/TUICallKit-Vue3/build/tang-detective-native-validation.json new file mode 100644 index 0000000..4bf0e6a --- /dev/null +++ b/TUICallKit-Vue3/build/tang-detective-native-validation.json @@ -0,0 +1,146 @@ +{ + "passed": true, + "nativePages": 24, + "mediaFiles": 204, + "relativeDependencies": 328, + "nativeSizes": { + "sourceFileBytes": 30098730, + "mainPackageBytes": 1903370, + "subPackages": { + "tang-detective/package-audio-c01-a": 1733274, + "tang-detective/package-audio-c01-b": 1231110, + "tang-detective/package-audio-player": 107278, + "tang-detective/package-chapter-02": 1571087, + "tang-detective/package-chapter-03": 1995560, + "tang-detective/package-chapter-04": 1180764, + "tang-detective/package-chapter-05": 1605611, + "tang-detective/package-chapter-06": 1707290, + "tang-detective/package-chapter-07": 1663468, + "tang-detective/package-chapter-08": 1747028, + "tang-detective/package-chapter-09": 1754206, + "tang-detective/package-chapter-10": 1783701, + "tang-detective/package-chapter-11": 1547432, + "tang-detective/package-chapter-12": 1709680, + "tang-detective/package-chapter-13": 1960330, + "tang-detective/package-chapter-14": 1877271, + "tang-detective/package-chapter-15": 1757833, + "tang-detective/package-game": 1262437 + }, + "mediaFileCount": 204, + "mediaBytes": 24052082 + }, + "outputSizes": { + "totalFileBytes": 32595927, + "mainPackageFileBytes": 3157378, + "subPackages": { + "doctor": 25092, + "tang-detective/package-audio-c01-a": 1733274, + "tang-detective/package-audio-c01-b": 1231110, + "tang-detective/package-audio-player": 107278, + "tang-detective/package-chapter-02": 1571087, + "tang-detective/package-chapter-03": 1995560, + "tang-detective/package-chapter-04": 1180764, + "tang-detective/package-chapter-05": 1605611, + "tang-detective/package-chapter-06": 1707290, + "tang-detective/package-chapter-07": 1663468, + "tang-detective/package-chapter-08": 1747028, + "tang-detective/package-chapter-09": 1754206, + "tang-detective/package-chapter-10": 1783701, + "tang-detective/package-chapter-11": 1547432, + "tang-detective/package-chapter-12": 1709680, + "tang-detective/package-chapter-13": 1960330, + "tang-detective/package-chapter-14": 1877271, + "tang-detective/package-chapter-15": 1757833, + "tang-detective/package-game": 1262437, + "tongji": 574912, + "training": 173057, + "TUICallKit/src/Components": 172402, + "TUIKit": 297726 + } + }, + "errors": [], + "boundary": "Static source/asset/dependency/manifest verification only. Raw file sizes are not WeChat upload package sizes; no simulator, device, audio listening, upload, or release acceptance was performed.", + "verificationDate": "2026-09-08", + "builds": { + "weixinExitCode": 0, + "h5ExitCode": 0 + }, + "tests": { + "nativeImport": 7, + "platform": 27, + "realPageLifecycle": 10, + "total": 44, + "failed": 0 + }, + "nativeCompiler": { + "nativePages": 24, + "results": [ + { + "tool": "wcc", + "exitCode": 0, + "passed": true, + "generatedOutputBytes": 1183577, + "diagnostics": "", + "error": null + }, + { + "tool": "wcsc", + "exitCode": 0, + "passed": true, + "generatedOutputBytes": 1346916, + "diagnostics": "", + "error": null + } + ], + "boundary": "Installed compiler syntax check only, not simulator/device, networking or upload acceptance." + }, + "provenance": { + "sourceFiles": 473, + "sourceDrift": [], + "sha256": { + "main.js": "ef7412ffcb94520615c4289c45ca63929de44f2258d73aa7134cb3dacebd7b11", + "package.json": "63ffaf1f3224ab685e6ba9c1895030d66400d8371b37786cb73e2e41b868b16c", + "pages.json": "3987ba37f2175ed100058861d5053726413232d6599fff4da807654d2661a17a", + "tongji/pages/weekly.vue": "c8033a6237c46f5e1d170628ba82a8638a4275388b4be5f6c9336d0552981cb7", + "config/api.js": "c0efc1db4daa21e91a404784170c12efc127a6fd042f476ad131aa4a2642ff74", + "vite.config.ts": "28efc83adbd45629731b83c7702e184ba775d32091150ede54874d0975bd1352", + "build/tang-detective-native-plugin.mjs": "7b64e465a246b7696019d17d7777a58a7ffa3a4ad4bfe7187ac09f5495954a30", + "dist/build/mp-weixin/app.json": "bd6aa0028069fd2b0801681e2e62ab8faf3ddc9544ea5375e8f7db5c33e6c92e", + "dist/build/mp-weixin/tang-detective/.native-import-state.json": "8518b430dbe156a19a6dd317034e8935090e7be805337ab35c29c944f5721b32", + "native-adapter/tang-detective/pages/catalog/catalog.js": "0f98ca3ec3c7f4d296afea27d6e2ec86f9a1456805c3cf12fafe622e2c597b90", + "native-adapter/tang-detective/pages/home/home.js": "880b7561c6a33ac0d45986ea492cdac56ad9fa837198c93f546291db7b2b1f35", + "native-adapter/tang-detective/pages/home/home.wxml": "d2ad74344fef441594817d9ecdb7273a743536a013b50e3bdaacd065882fe10d", + "native-adapter/tang-detective/pages/home/home.wxss": "c00be0cf7c8bbf4ed50f9ad0eeb80922424b6b7e0c58f89edc873c2625774c2c", + "native-adapter/tang-detective/utils/identityHash.js": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a", + "native-adapter/tang-detective/utils/platformBridge.js": "d0fac62908d4caab65864d0c401f621561525aa32be12d536ecb9070988e9aaa", + "native-adapter/tang-detective/utils/platformCore.js": "398f822d4dd492f962957d18e997138eda3dc10e42e6b0b0e8c453f40f324927", + "native-adapter/tang-detective/utils/progressContract.js": "fb3e1107298ca11983f1136dc13105588c815c3240e289baab2bcaab88cf71f0", + "native-adapter/tang-detective/utils/storage.js": "f6028151609f3a02efd633ca7d696300ee7e6e5eeb06ee0edcee01f1e08f9835", + "native-adapter/tang-detective/utils/tangPage.js": "9aeafc0b944b4adc87179144d52f351a60ce20b0c6b9e466c32418367db47e35" + } + }, + "independentReview": { + "originalFindings": 5, + "closedByOfflineRetest": 5, + "productionApproval": false + }, + "notRun": [ + "PHP contract scripts: PHP runtime unavailable", + "MySQL and SQL migration", + "live HTTP API/authentication integration", + "simulator interaction and real-device testing", + "audio listening and media rights approval", + "upload and production deployment" + ], + "resources": { + "taskProcesses": "exited", + "taskServersStarted": 0, + "browserOrPlayerOpened": false, + "preserved": [ + "source snapshots", + "dependencies and SDK", + "build outputs", + "user-existing applications and services" + ] + } +} diff --git a/TUICallKit-Vue3/build/tang-detective-source-manifest.json b/TUICallKit-Vue3/build/tang-detective-source-manifest.json new file mode 100644 index 0000000..d06f12c --- /dev/null +++ b/TUICallKit-Vue3/build/tang-detective-source-manifest.json @@ -0,0 +1,2374 @@ +{ + "version": 1, + "excludedRootFiles": [ + "app.js", + "sitemap.json" + ], + "files": [ + { + "path": "app.json", + "bytes": 3135, + "sha256": "0512f794b3883e3be30f081ee4b56ebc6a9aed1996957fb8a9b410eb193fd13d" + }, + { + "path": "app.wxss", + "bytes": 3570, + "sha256": "23939ea4d2108f37ddab458c4c5552dd4a266cfeb7ef7a40b6c1594c1129133a" + }, + { + "path": "assets/characters/female-cook.jpg", + "bytes": 11958, + "sha256": "c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f" + }, + { + "path": "assets/characters/lele.jpg", + "bytes": 102681, + "sha256": "807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168" + }, + { + "path": "assets/characters/lin-xiulan.jpg", + "bytes": 107355, + "sha256": "56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361" + }, + { + "path": "assets/characters/qin-xiaoman.jpg", + "bytes": 92914, + "sha256": "22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5" + }, + { + "path": "assets/characters/qin-zhicheng.jpg", + "bytes": 116799, + "sha256": "7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2" + }, + { + "path": "assets/characters/tang-mingyuan.jpg", + "bytes": 92880, + "sha256": "5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb" + }, + { + "path": "assets/characters/tang-shouan.jpg", + "bytes": 108663, + "sha256": "4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89" + }, + { + "path": "assets/characters/xiaozhen.jpg", + "bytes": 89720, + "sha256": "d5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb" + }, + { + "path": "assets/characters/zhao-jianguo.jpg", + "bytes": 100165, + "sha256": "1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0" + }, + { + "path": "assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg", + "bytes": 97655, + "sha256": "f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0" + }, + { + "path": "assets/scenes/gui-xiang-1978.jpg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5" + }, + { + "path": "assets/scenes/gui-xiang-1995.jpg", + "bytes": 67246, + "sha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f" + }, + { + "path": "assets/scenes/gui-xiang-1998.jpg", + "bytes": 62727, + "sha256": "d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14" + }, + { + "path": "assets/scenes/gui-xiang-2001.jpg", + "bytes": 59408, + "sha256": "9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8" + }, + { + "path": "assets/scenes/gui-xiang-2003.jpg", + "bytes": 53822, + "sha256": "68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431" + }, + { + "path": "assets/scenes/gui-xiang-2008.jpg", + "bytes": 53286, + "sha256": "c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5" + }, + { + "path": "assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7" + }, + { + "path": "assets/share/guixiang-story-share-preview-v1.jpg", + "bytes": 99232, + "sha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69" + }, + { + "path": "data/cast.js", + "bytes": 2977, + "sha256": "65be0bcf6e9d2cfd8c0b70bb4e2e833e46f7d5eb15ed6942b4a7c4f9e15c1bab" + }, + { + "path": "data/chapters.js", + "bytes": 865, + "sha256": "21298bcd8358feffc6d5a36258c7c4fbdeeeaefa669aad3cf96607078230ac7c" + }, + { + "path": "data/memoryCards.js", + "bytes": 11549, + "sha256": "698f905b7e50d86d0c77ca4e72e5f943663c8ced1f79ead7151ce0aac0846e8f" + }, + { + "path": "data/productionComicPages.js", + "bytes": 72363, + "sha256": "3f235e492302a0a700ffb89f49ef925adb707cd5de85bb62b0b83bfd58d278f0" + }, + { + "path": "data/releaseInfo.js", + "bytes": 415, + "sha256": "0db29638c7f1cfd4edac4a742500e0bdf17aaf5ded2ef2896a098262d27c033b" + }, + { + "path": "data/season.js", + "bytes": 218618, + "sha256": "8f53063ee60bd288b19d71c1fd8d841633b25403f784dc57435dfe9256a92116" + }, + { + "path": "package-audio-c01-a/assets/audio/S01-C01-P01.daa8f04a2627.mp3", + "bytes": 270139, + "sha256": "daa8f04a262767109f3a0621f178e3c58f169507e8ea09fa8d583258cb713282" + }, + { + "path": "package-audio-c01-a/assets/audio/S01-C01-P02.7774981b1878.mp3", + "bytes": 396572, + "sha256": "7774981b1878f6ad882cb7a4e3f923d67f43c12c2ea068982df1cab63820d8c2" + }, + { + "path": "package-audio-c01-a/assets/audio/S01-C01-P03.0f21daa7eccb.mp3", + "bytes": 244853, + "sha256": "0f21daa7eccbaac9139317e8209570fe025fb2403ea5cc794e12586764365885" + }, + { + "path": "package-audio-c01-a/assets/audio/S01-C01-P04.ed65c367d761.mp3", + "bytes": 375047, + "sha256": "ed65c367d76125e1404d76bb86faa0629b599f6853a39e58821617f854bfa167" + }, + { + "path": "package-audio-c01-a/assets/player-art/S01-C01-P01-title-v1.jpg", + "bytes": 105278, + "sha256": "12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a" + }, + { + "path": "package-audio-c01-a/assets/player-art/S01-C01-P02-missing-table-ensemble-v1.jpg", + "bytes": 97655, + "sha256": "f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0" + }, + { + "path": "package-audio-c01-a/assets/player-art/S01-C01-P03-H01-scan-only-v1.jpg", + "bytes": 91819, + "sha256": "6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80" + }, + { + "path": "package-audio-c01-a/assets/player-art/S01-C01-P04-H02-extra-dish-v1.jpg", + "bytes": 120832, + "sha256": "bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce" + }, + { + "path": "package-audio-c01-a/data/audioPages.js", + "bytes": 2569, + "sha256": "b4d9213b408805d9b218ce60e13fa7b2c14733c1f258937a412b7dcd5784699e" + }, + { + "path": "package-audio-c01-a/pages/player/player.js", + "bytes": 20842, + "sha256": "f081d5e8b11489e5dae1a3c621b5c6416661b9d2ef68937bf094c98058b49779" + }, + { + "path": "package-audio-c01-a/pages/player/player.json", + "bytes": 28, + "sha256": "a4a6ce4b4a53e89610d78055507416398e02427786d8f702d2864b5c1e0027c0" + }, + { + "path": "package-audio-c01-a/pages/player/player.wxml", + "bytes": 2508, + "sha256": "8e8bb322083b8923527d80bb0a320e725ac156cc13cb2f13db72f7101dd00b47" + }, + { + "path": "package-audio-c01-a/pages/player/player.wxss", + "bytes": 4637, + "sha256": "d4ef6469e03922b9566ce34556d67d9534302fb8848c01e922b6c6a190fbaa83" + }, + { + "path": "package-audio-c01-b/assets/audio/S01-C01-P05.210005d24e32.mp3", + "bytes": 263661, + "sha256": "210005d24e3276e2689e38b0bb3a9d6b6e900ca3cc5ca874278a0b89a50def6a" + }, + { + "path": "package-audio-c01-b/assets/audio/S01-C01-P06.5e69ed4c17f3.mp3", + "bytes": 107344, + "sha256": "5e69ed4c17f3d5c4168546c0b76a6d5bfb13ecbecdd982c5be1c27259b9ce1a4" + }, + { + "path": "package-audio-c01-b/assets/audio/S01-C01-P07.a68bc0cb8ecc.mp3", + "bytes": 84356, + "sha256": "a68bc0cb8ecc6d53b7c877567d6c21fdd894c914e84c072357116429d05d0cc5" + }, + { + "path": "package-audio-c01-b/assets/audio/S01-C01-P08.1c44eff9d71f.mp3", + "bytes": 316951, + "sha256": "1c44eff9d71fcd004e53050e0727ee37fa3560509d3579c0e2cd004e80e246ec" + }, + { + "path": "package-audio-c01-b/assets/player-art/S01-C01-P05-H03-four-imprints-v1.jpg", + "bytes": 111834, + "sha256": "d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f" + }, + { + "path": "package-audio-c01-b/assets/player-art/S01-C01-P06-H04-unsaid-speech-v1.jpg", + "bytes": 120294, + "sha256": "7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750" + }, + { + "path": "package-audio-c01-b/assets/player-art/S01-C01-P07-EM01-call-zhao-in-v1.jpg", + "bytes": 96418, + "sha256": "4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31" + }, + { + "path": "package-audio-c01-b/assets/player-art/S01-C01-P08-cliffhanger-v1.jpg", + "bytes": 99232, + "sha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69" + }, + { + "path": "package-audio-c01-b/data/audioPages.js", + "bytes": 2510, + "sha256": "33d91f7b276fc9e8f142db056ceff912af7da63d15cefbb88a553c34b073e6a9" + }, + { + "path": "package-audio-c01-b/pages/player/player.js", + "bytes": 20842, + "sha256": "f081d5e8b11489e5dae1a3c621b5c6416661b9d2ef68937bf094c98058b49779" + }, + { + "path": "package-audio-c01-b/pages/player/player.json", + "bytes": 28, + "sha256": "a4a6ce4b4a53e89610d78055507416398e02427786d8f702d2864b5c1e0027c0" + }, + { + "path": "package-audio-c01-b/pages/player/player.wxml", + "bytes": 2508, + "sha256": "8e8bb322083b8923527d80bb0a320e725ac156cc13cb2f13db72f7101dd00b47" + }, + { + "path": "package-audio-c01-b/pages/player/player.wxss", + "bytes": 4637, + "sha256": "d4ef6469e03922b9566ce34556d67d9534302fb8848c01e922b6c6a190fbaa83" + }, + { + "path": "package-audio-player/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-audio-player/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-audio-player/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-audio-player/pages/player/player.js", + "bytes": 25953, + "sha256": "f5adb542c813a4c2c8034b25b73444283114a212c82baef1cba6f4c2c114295c" + }, + { + "path": "package-audio-player/pages/player/player.json", + "bytes": 28, + "sha256": "a4a6ce4b4a53e89610d78055507416398e02427786d8f702d2864b5c1e0027c0" + }, + { + "path": "package-audio-player/pages/player/player.wxml", + "bytes": 3197, + "sha256": "8681d94aad027b131c6a607ff4f5f638eba5066b7af57c767c619e355e3efb81" + }, + { + "path": "package-audio-player/pages/player/player.wxss", + "bytes": 5909, + "sha256": "6b38b5dff9c82aadb373edb05c1724c32179d66c7d6635f9517f3843c6bf9e62" + }, + { + "path": "package-audio-player/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-audio-player/utils/chapterRoute.js", + "bytes": 773, + "sha256": "3b5ffb45dc73328e209f2ed476502bbac58b3b1c0835d5113b39ea09d40c17e8" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS001.mp3", + "bytes": 19125, + "sha256": "580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS002-ATTR.mp3", + "bytes": 4797, + "sha256": "aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS002.mp3", + "bytes": 6417, + "sha256": "ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS003.mp3", + "bytes": 19053, + "sha256": "3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS004.mp3", + "bytes": 8505, + "sha256": "e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS005.mp3", + "bytes": 7749, + "sha256": "6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS006.mp3", + "bytes": 12465, + "sha256": "067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS007.mp3", + "bytes": 12213, + "sha256": "7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS008.mp3", + "bytes": 12465, + "sha256": "288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS009.mp3", + "bytes": 6993, + "sha256": "5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS010.mp3", + "bytes": 27189, + "sha256": "d371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS011.mp3", + "bytes": 15741, + "sha256": "2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS012.mp3", + "bytes": 32697, + "sha256": "33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MT000.mp3", + "bytes": 4005, + "sha256": "5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-TE900.mp3", + "bytes": 7713, + "sha256": "d57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg", + "bytes": 103969, + "sha256": "c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg", + "bytes": 128391, + "sha256": "892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg", + "bytes": 110191, + "sha256": "e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg", + "bytes": 125153, + "sha256": "ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg", + "bytes": 128039, + "sha256": "cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg", + "bytes": 99846, + "sha256": "61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg", + "bytes": 133519, + "sha256": "c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg", + "bytes": 128463, + "sha256": "6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703" + }, + { + "path": "package-chapter-02/assets/scenes/gui-xiang-1978.jpg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5" + }, + { + "path": "package-chapter-02/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-02/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-02/data/productionComicPages.js", + "bytes": 88, + "sha256": "bea99e5e450275b719f909a79cc2d7fefb3638ac461064a3094474a18472c512" + }, + { + "path": "package-chapter-02/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-02/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-02/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-02/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-02/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-02/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-02/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-02/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-02/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-02/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-02/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS001.mp3", + "bytes": 25821, + "sha256": "b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS002-ATTR.mp3", + "bytes": 10557, + "sha256": "ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS002.mp3", + "bytes": 6489, + "sha256": "c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS003.mp3", + "bytes": 20205, + "sha256": "af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS004.mp3", + "bytes": 7749, + "sha256": "c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS005-ATTR.mp3", + "bytes": 5517, + "sha256": "4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS005.mp3", + "bytes": 10053, + "sha256": "46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS006.mp3", + "bytes": 13401, + "sha256": "2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS007-ATTR.mp3", + "bytes": 6309, + "sha256": "f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS007.mp3", + "bytes": 6849, + "sha256": "68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS008.mp3", + "bytes": 5373, + "sha256": "d9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS009.mp3", + "bytes": 20925, + "sha256": "97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS010.mp3", + "bytes": 6165, + "sha256": "49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS011.mp3", + "bytes": 15777, + "sha256": "6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS013.mp3", + "bytes": 19017, + "sha256": "8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS014.mp3", + "bytes": 4869, + "sha256": "8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS015.mp3", + "bytes": 14193, + "sha256": "661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MT000.mp3", + "bytes": 4077, + "sha256": "374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-TE900.mp3", + "bytes": 6813, + "sha256": "f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg", + "bytes": 118129, + "sha256": "7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg", + "bytes": 194945, + "sha256": "feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg", + "bytes": 207111, + "sha256": "841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg", + "bytes": 199826, + "sha256": "8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg", + "bytes": 171496, + "sha256": "cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg", + "bytes": 84540, + "sha256": "00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg", + "bytes": 182868, + "sha256": "787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg", + "bytes": 210097, + "sha256": "056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5" + }, + { + "path": "package-chapter-03/assets/scenes/gui-xiang-1978.jpg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5" + }, + { + "path": "package-chapter-03/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-03/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-03/data/productionComicPages.js", + "bytes": 88, + "sha256": "bea99e5e450275b719f909a79cc2d7fefb3638ac461064a3094474a18472c512" + }, + { + "path": "package-chapter-03/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-03/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-03/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-03/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-03/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-03/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-03/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-03/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-03/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-03/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-03/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg", + "bytes": 83596, + "sha256": "e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg", + "bytes": 105099, + "sha256": "d83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg", + "bytes": 91939, + "sha256": "c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg", + "bytes": 89468, + "sha256": "70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg", + "bytes": 93259, + "sha256": "4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg", + "bytes": 92724, + "sha256": "25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg", + "bytes": 98885, + "sha256": "3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg", + "bytes": 104015, + "sha256": "9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61" + }, + { + "path": "package-chapter-04/assets/scenes/gui-xiang-1978.jpg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5" + }, + { + "path": "package-chapter-04/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-04/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-04/data/productionComicPages.js", + "bytes": 5478, + "sha256": "23820e31657f0624346ba82bab7279929b0725d70d382286c5a7c43b747655dd" + }, + { + "path": "package-chapter-04/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-04/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-04/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-04/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-04/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-04/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-04/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-04/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-04/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-04/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-04/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg", + "bytes": 113102, + "sha256": "52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg", + "bytes": 156680, + "sha256": "897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg", + "bytes": 147490, + "sha256": "76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg", + "bytes": 124380, + "sha256": "c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg", + "bytes": 159817, + "sha256": "a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg", + "bytes": 146026, + "sha256": "63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg", + "bytes": 150951, + "sha256": "172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg", + "bytes": 190776, + "sha256": "b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f" + }, + { + "path": "package-chapter-05/assets/scenes/gui-xiang-1978.jpg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5" + }, + { + "path": "package-chapter-05/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-05/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-05/data/productionComicPages.js", + "bytes": 88, + "sha256": "bea99e5e450275b719f909a79cc2d7fefb3638ac461064a3094474a18472c512" + }, + { + "path": "package-chapter-05/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-05/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-05/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-05/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-05/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-05/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-05/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-05/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-05/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-05/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-05/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg", + "bytes": 190405, + "sha256": "f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg", + "bytes": 140303, + "sha256": "0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg", + "bytes": 181320, + "sha256": "caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg", + "bytes": 150118, + "sha256": "f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg", + "bytes": 154740, + "sha256": "408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg", + "bytes": 142954, + "sha256": "84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg", + "bytes": 152752, + "sha256": "c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg", + "bytes": 170210, + "sha256": "bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5" + }, + { + "path": "package-chapter-06/assets/scenes/gui-xiang-1995.jpg", + "bytes": 67246, + "sha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f" + }, + { + "path": "package-chapter-06/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-06/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-06/data/productionComicPages.js", + "bytes": 6133, + "sha256": "071e8140fcd61acef3a3bf799bfef6609c835ebbb43590ab7d32bd96ff5715a5" + }, + { + "path": "package-chapter-06/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-06/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-06/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-06/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-06/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-06/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-06/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-06/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-06/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-06/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-06/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg", + "bytes": 165311, + "sha256": "42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg", + "bytes": 173413, + "sha256": "81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg", + "bytes": 128377, + "sha256": "fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg", + "bytes": 147055, + "sha256": "8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg", + "bytes": 157850, + "sha256": "5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg", + "bytes": 174837, + "sha256": "7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg", + "bytes": 130849, + "sha256": "19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg", + "bytes": 161095, + "sha256": "f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e" + }, + { + "path": "package-chapter-07/assets/scenes/gui-xiang-1995.jpg", + "bytes": 67246, + "sha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f" + }, + { + "path": "package-chapter-07/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-07/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-07/data/productionComicPages.js", + "bytes": 6326, + "sha256": "903a0fa029b86b2bcd4f9d233064feeea09d03f97e2d1e36cc546ab046bac3a1" + }, + { + "path": "package-chapter-07/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-07/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-07/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-07/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-07/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-07/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-07/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-07/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-07/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-07/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-07/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg", + "bytes": 157367, + "sha256": "b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg", + "bytes": 191697, + "sha256": "4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg", + "bytes": 198741, + "sha256": "2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg", + "bytes": 178634, + "sha256": "737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg", + "bytes": 180813, + "sha256": "41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg", + "bytes": 163905, + "sha256": "fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg", + "bytes": 116785, + "sha256": "6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg", + "bytes": 138725, + "sha256": "2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743" + }, + { + "path": "package-chapter-08/assets/scenes/gui-xiang-1998.jpg", + "bytes": 62727, + "sha256": "d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14" + }, + { + "path": "package-chapter-08/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-08/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-08/data/productionComicPages.js", + "bytes": 6525, + "sha256": "2abc3010d9a1bd916e194aef0b1a1c72dba3d47ee2c6c2f89438816304ddb952" + }, + { + "path": "package-chapter-08/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-08/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-08/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-08/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-08/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-08/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-08/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-08/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-08/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-08/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-08/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg", + "bytes": 150476, + "sha256": "59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg", + "bytes": 179019, + "sha256": "0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg", + "bytes": 192720, + "sha256": "9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg", + "bytes": 139199, + "sha256": "cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg", + "bytes": 164727, + "sha256": "5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg", + "bytes": 180695, + "sha256": "5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg", + "bytes": 176826, + "sha256": "9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg", + "bytes": 152317, + "sha256": "b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724" + }, + { + "path": "package-chapter-09/assets/scenes/gui-xiang-2001.jpg", + "bytes": 59408, + "sha256": "9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8" + }, + { + "path": "package-chapter-09/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-09/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-09/data/productionComicPages.js", + "bytes": 7710, + "sha256": "b7c51047d396f4ae40115c6671fd198a40b394551cc9cdb6fe8393bee6edf491" + }, + { + "path": "package-chapter-09/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-09/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-09/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-09/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-09/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-09/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-09/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-09/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-09/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-09/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-09/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg", + "bytes": 170147, + "sha256": "42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg", + "bytes": 201027, + "sha256": "30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg", + "bytes": 145630, + "sha256": "23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg", + "bytes": 142107, + "sha256": "be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg", + "bytes": 189031, + "sha256": "a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg", + "bytes": 161904, + "sha256": "6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg", + "bytes": 157096, + "sha256": "13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg", + "bytes": 205032, + "sha256": "2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0" + }, + { + "path": "package-chapter-10/assets/scenes/gui-xiang-2003.jpg", + "bytes": 53822, + "sha256": "68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431" + }, + { + "path": "package-chapter-10/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-10/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-10/data/productionComicPages.js", + "bytes": 6796, + "sha256": "dc39195a47156ab98293602d1037e6b6ac8398c654c88ec410d9e07df26c1313" + }, + { + "path": "package-chapter-10/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-10/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-10/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-10/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-10/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-10/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-10/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-10/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-10/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-10/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-10/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg", + "bytes": 170560, + "sha256": "840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg", + "bytes": 122506, + "sha256": "112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg", + "bytes": 95514, + "sha256": "23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg", + "bytes": 185485, + "sha256": "3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg", + "bytes": 120040, + "sha256": "c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg", + "bytes": 172410, + "sha256": "4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg", + "bytes": 157302, + "sha256": "9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg", + "bytes": 112557, + "sha256": "b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de" + }, + { + "path": "package-chapter-11/assets/scenes/gui-xiang-2008.jpg", + "bytes": 53286, + "sha256": "c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5" + }, + { + "path": "package-chapter-11/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-11/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-11/data/productionComicPages.js", + "bytes": 6663, + "sha256": "1c0ccd655e45288505159d253688bb60561508a864f87833b0c38ea2158b59cc" + }, + { + "path": "package-chapter-11/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-11/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-11/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-11/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-11/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-11/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-11/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-11/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-11/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-11/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-11/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg", + "bytes": 167948, + "sha256": "e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg", + "bytes": 190911, + "sha256": "6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg", + "bytes": 133362, + "sha256": "c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg", + "bytes": 152941, + "sha256": "1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg", + "bytes": 164550, + "sha256": "695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg", + "bytes": 153998, + "sha256": "9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg", + "bytes": 195056, + "sha256": "338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg", + "bytes": 135599, + "sha256": "a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f" + }, + { + "path": "package-chapter-12/assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7" + }, + { + "path": "package-chapter-12/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-12/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-12/data/productionComicPages.js", + "bytes": 7326, + "sha256": "4b297cb7e00bde13356e3ddbbab8839f0fd5679668dec2f0774550085888dbd0" + }, + { + "path": "package-chapter-12/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-12/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-12/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-12/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-12/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-12/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-12/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-12/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-12/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-12/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-12/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg", + "bytes": 191222, + "sha256": "2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg", + "bytes": 201951, + "sha256": "a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg", + "bytes": 201320, + "sha256": "13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg", + "bytes": 173154, + "sha256": "d4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg", + "bytes": 214867, + "sha256": "2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg", + "bytes": 184565, + "sha256": "8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg", + "bytes": 198924, + "sha256": "edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg", + "bytes": 179621, + "sha256": "f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc" + }, + { + "path": "package-chapter-13/assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7" + }, + { + "path": "package-chapter-13/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-13/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-13/data/productionComicPages.js", + "bytes": 6717, + "sha256": "828e335385ef32427ebcc483c1fbe4a610bb4eb3bb8ba3e3138fa400e130c33a" + }, + { + "path": "package-chapter-13/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-13/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-13/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-13/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-13/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-13/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-13/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-13/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-13/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-13/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-13/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg", + "bytes": 173212, + "sha256": "6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg", + "bytes": 204940, + "sha256": "15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg", + "bytes": 181140, + "sha256": "9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg", + "bytes": 190877, + "sha256": "899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg", + "bytes": 181882, + "sha256": "b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg", + "bytes": 192247, + "sha256": "c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg", + "bytes": 166223, + "sha256": "d0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg", + "bytes": 172139, + "sha256": "09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c" + }, + { + "path": "package-chapter-14/assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7" + }, + { + "path": "package-chapter-14/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-14/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-14/data/productionComicPages.js", + "bytes": 6622, + "sha256": "6d62c5acbcf69426dd48e3f98e0aea67b1e691da080053cc484546b07b2a77ab" + }, + { + "path": "package-chapter-14/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-14/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-14/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-14/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-14/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-14/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-14/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-14/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-14/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-14/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-14/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg", + "bytes": 207370, + "sha256": "194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg", + "bytes": 189454, + "sha256": "b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg", + "bytes": 162103, + "sha256": "b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg", + "bytes": 138672, + "sha256": "2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg", + "bytes": 166021, + "sha256": "21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg", + "bytes": 172654, + "sha256": "3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg", + "bytes": 210429, + "sha256": "98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg", + "bytes": 96215, + "sha256": "520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113" + }, + { + "path": "package-chapter-15/assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7" + }, + { + "path": "package-chapter-15/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-15/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-15/data/productionComicPages.js", + "bytes": 6926, + "sha256": "4c08ba201eb03c87ddc3f3eea1c6a69b87ee6ccae9e4184651b65b136538891e" + }, + { + "path": "package-chapter-15/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-15/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-15/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-15/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-15/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-15/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-15/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-15/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-15/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-15/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-15/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-game/assets/audio/S01-C01-MS007.mp3", + "bytes": 18957, + "sha256": "1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8" + }, + { + "path": "package-game/assets/audio/S01-C01-MS010.mp3", + "bytes": 17421, + "sha256": "628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597" + }, + { + "path": "package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg", + "bytes": 105278, + "sha256": "12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a" + }, + { + "path": "package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg", + "bytes": 91819, + "sha256": "6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80" + }, + { + "path": "package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg", + "bytes": 120832, + "sha256": "bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce" + }, + { + "path": "package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg", + "bytes": 111834, + "sha256": "d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f" + }, + { + "path": "package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg", + "bytes": 120294, + "sha256": "7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750" + }, + { + "path": "package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg", + "bytes": 96418, + "sha256": "4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31" + }, + { + "path": "package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg", + "bytes": 99232, + "sha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69" + }, + { + "path": "package-game/assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7" + }, + { + "path": "package-game/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-game/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-game/data/productionComicPages.js", + "bytes": 72363, + "sha256": "3f235e492302a0a700ffb89f49ef925adb707cd5de85bb62b0b83bfd58d278f0" + }, + { + "path": "package-game/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-game/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-game/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-game/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-game/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-game/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-game/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-game/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-game/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-game/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-game/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "pages/cast/cast.js", + "bytes": 3899, + "sha256": "5e8be67aceb2ccc092131bb83ec166ef0749c869d1d3febb2fbd9ac97f2ab5ab" + }, + { + "path": "pages/cast/cast.json", + "bytes": 81, + "sha256": "167266925f050d73edf58df9dabd5b9bd50132feda9b09eb297d2cc7b3d2d76d" + }, + { + "path": "pages/cast/cast.wxml", + "bytes": 4391, + "sha256": "a24e5f69d3c3fd35e989aef02a2e4212d0f22d6daf0fda4765583ab46bbe3906" + }, + { + "path": "pages/cast/cast.wxss", + "bytes": 10988, + "sha256": "c76eb7cac43ecb638b5fee79a02fc17b79382ad92373d87eaf0941ea53da1708" + }, + { + "path": "pages/catalog/catalog.js", + "bytes": 2780, + "sha256": "1a38ec886f237cf0b5a25391c644b327f4a2e9bcc14129b0b3c0be41cb50236e" + }, + { + "path": "pages/catalog/catalog.json", + "bytes": 84, + "sha256": "7cfb1559feca77fdb1c4dcfee33541f23e7e01790f0068cc58bffad57785d39a" + }, + { + "path": "pages/catalog/catalog.wxml", + "bytes": 2577, + "sha256": "eeecc409aa288ecdeba2d86d17dae4c572206d06877d62f2d112a21646f0b28c" + }, + { + "path": "pages/catalog/catalog.wxss", + "bytes": 5772, + "sha256": "e108101f1b77240a0da024839d00376d244e20cb06378afd29a5341c372af17c" + }, + { + "path": "pages/home/home.js", + "bytes": 2777, + "sha256": "c2a6d6d4f9a8934b2b443109e1d885d92af7909a4309afedd15b0faeab761366" + }, + { + "path": "pages/home/home.json", + "bytes": 78, + "sha256": "7cc077cf2e3e9c63b4d76333dedc75c74c538eb53bde0cf4518183dcfbe2ffc7" + }, + { + "path": "pages/home/home.wxml", + "bytes": 2838, + "sha256": "5f4993bf76271773da356858cd55882a390bb2e3c53ebb643d0f098ee4087f21" + }, + { + "path": "pages/home/home.wxss", + "bytes": 12444, + "sha256": "1f2baf22d7d1fe1f968b1657843ab2a66bd15b947099c7da0caf14d0b1fd9b45" + }, + { + "path": "pages/home/homeLayout.js", + "bytes": 3573, + "sha256": "30049b83e8ba65185d21dcec2bb683d608511f7396dc135bc0b5d1d787cdebb6" + }, + { + "path": "pages/memories/memories.js", + "bytes": 1809, + "sha256": "f407895e01cb89bb3b4d64c8ac8bbb1e0d037619ef19b043930303b1ec13757b" + }, + { + "path": "pages/memories/memories.json", + "bytes": 59, + "sha256": "27041e9bbf576a46ddf154516f63f0cc101c2c02ce6853781fae61a52fc9cd16" + }, + { + "path": "pages/memories/memories.wxml", + "bytes": 4009, + "sha256": "7a5329563d46ff310dc423dedaa231648e809ab8215a3d62d18abfcef5d002a7" + }, + { + "path": "pages/memories/memories.wxss", + "bytes": 9477, + "sha256": "328792fa9a335fd3dd2a4024919876fad2a0b421eaa9d5deb8a7f7203de15813" + }, + { + "path": "pages/report/report.js", + "bytes": 2111, + "sha256": "96b0ea05d71f4f8ee91257077a0e71aed411a75c244ff59607082b842b63a91a" + }, + { + "path": "pages/report/report.json", + "bytes": 59, + "sha256": "27041e9bbf576a46ddf154516f63f0cc101c2c02ce6853781fae61a52fc9cd16" + }, + { + "path": "pages/report/report.wxml", + "bytes": 3447, + "sha256": "3a264231b5f61522c94325c2dc81fc77ea090b53cc46e9a5b4a6d008ac692f47" + }, + { + "path": "pages/report/report.wxss", + "bytes": 4096, + "sha256": "2b8d805b37630257c2aacbd84291c92a2822846bf58e12a852bf77c449be8c6f" + }, + { + "path": "pages/share/share.js", + "bytes": 3043, + "sha256": "60673512d2f4b9ffe8d5df57c313c8e9a4b99866f2a2cc3b743c2a944deb9a73" + }, + { + "path": "pages/share/share.json", + "bytes": 59, + "sha256": "27041e9bbf576a46ddf154516f63f0cc101c2c02ce6853781fae61a52fc9cd16" + }, + { + "path": "pages/share/share.wxml", + "bytes": 2785, + "sha256": "0adab4715f1065d13d3053b0f96e7deb0bff3c0970cbeceaa401729ccc3ca15c" + }, + { + "path": "pages/share/share.wxss", + "bytes": 5355, + "sha256": "9ad0e375f8d440707b123b8081f501a1784834e41d4dd3ff53da2bbf5c9f84ee" + }, + { + "path": "utils/chapterProgress.js", + "bytes": 4803, + "sha256": "d00f3ab136e1cb423aac2df192ccb55e1b6e431c4bd1ecfc038ac2a8b8ecde39" + }, + { + "path": "utils/chapterRoute.js", + "bytes": 773, + "sha256": "3b5ffb45dc73328e209f2ed476502bbac58b3b1c0835d5113b39ea09d40c17e8" + }, + { + "path": "utils/comicLayout.js", + "bytes": 2726, + "sha256": "ad07505047a1d3e65fa0518ffe87c5c148c23e5e7cc54970ba10a0fc6c6b7b06" + }, + { + "path": "utils/layout.js", + "bytes": 5591, + "sha256": "546ec87b14aef18b1deb3fc5dc148c827da6c9248bbd59bc0576dfd5df3cafe9" + }, + { + "path": "utils/memoryCollection.js", + "bytes": 2505, + "sha256": "1c1296f14853b5b1ae107807347ffcbbe31a1feea416f37416c9bfa944b8e07e" + }, + { + "path": "utils/storage.js", + "bytes": 3587, + "sha256": "9d7750ba6a244e7e9420cf2571833786d76c94d8f8d78611efe965f18b55f403" + }, + { + "path": "utils/updateManager.js", + "bytes": 1509, + "sha256": "37f68e58b5c3b950ddd209d39359b93d11da89f14944c60d2710ba0e9de781e7" + } + ] +} diff --git a/TUICallKit-Vue3/build/validate-tang-detective-output.mjs b/TUICallKit-Vue3/build/validate-tang-detective-output.mjs new file mode 100644 index 0000000..18d9ce1 --- /dev/null +++ b/TUICallKit-Vue3/build/validate-tang-detective-output.mjs @@ -0,0 +1,110 @@ +import fs from 'node:fs' +import path from 'node:path' +import vm from 'node:vm' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { createNativeManifest, createNativeOutputGuard, listFiles, sha256 } from './tang-detective-native-plugin.mjs' +import { isMediaFile, loadCosMediaManifest } from './tang-detective-cos-media.mjs' + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +export function validateNativeOutput(outputDirectory, { mediaManifest, mediaManifestPath } = {}) { + const outputGuard = createNativeOutputGuard(outputDirectory) + const output = outputGuard.root + const nativeRoot = outputGuard.path('tang-detective', 'directory') + const sourceRoot = path.join(projectRoot, 'native/tang-detective') + const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'app.json'), 'utf8')) + const manifest = createNativeManifest(sourceApp) + const hostApp = JSON.parse(fs.readFileSync(outputGuard.path('app.json'), 'utf8')) + const imported = JSON.parse(fs.readFileSync(outputGuard.path('tang-detective/.native-import-state.json'), 'utf8')) + const sourceManifest = JSON.parse(fs.readFileSync(path.join(projectRoot, 'build/tang-detective-source-manifest.json'), 'utf8')) + const localRequire = createRequire(path.join(output, 'native-validation.cjs')) + const errors = [] + const check = (condition, message) => { if (!condition) errors.push(message) } + const remoteMode = imported.media?.mode === 'cos' + let media = null + if (remoteMode) { + try { + media = loadCosMediaManifest({ sourceDirectory: sourceRoot, mediaManifest, mediaManifestPath }) + check(Boolean(media), 'COS output requires its verified media manifest') + if (media) { + check(imported.media.manifestSha256 === media.manifestSha256, 'COS manifest changed after import') + check(imported.media.sourceManifestSha256 === media.sourceManifestSha256, 'COS source manifest changed after import') + } + } catch (error) { errors.push(error.message) } + } + const allRoutes = [...hostApp.pages, ...(hostApp.subPackages || hostApp.subpackages || []).flatMap(item => item.pages.map(page => `${item.root}/${page}`))] + const packageOwner = relative => manifest.subPackages.find(item => relative.startsWith(`${item.root}/`))?.root || 'main' + let mediaFiles = 0 + for (const file of sourceManifest.files) { + check(sha256(fs.readFileSync(path.join(sourceRoot, file.path))) === file.sha256, `Source snapshot drift: ${file.path}`) + if (!isMediaFile(file.path)) continue + mediaFiles += 1 + outputGuard.path(`tang-detective/${file.path}`) + if (remoteMode) { + check(!fs.existsSync(path.join(nativeRoot, file.path)), `Packaged media remains in COS mode: ${file.path}`) + check(media?.entries.get(file.path)?.sha256 === file.sha256, `Remote media mapping changed: ${file.path}`) + } else { + check(sha256(fs.readFileSync(path.join(nativeRoot, file.path))) === file.sha256, `Media changed: ${file.path}`) + } + } + for (const file of imported.files) { + check(sha256(fs.readFileSync(outputGuard.path(`tang-detective/${file.path}`))) === file.sha256, `Native output changed after import: ${file.path}`) + } + const packagedMediaFiles = listFiles(nativeRoot).filter(isMediaFile).length + if (remoteMode) check(packagedMediaFiles === 0, 'Unexpected native packaged media in COS mode') + for (const page of manifest.allPages) { + check(allRoutes.filter(route => route === page).length === 1, `Page registration missing or duplicated: ${page}`) + for (const extension of ['.js', '.json', '.wxml', '.wxss']) check(fs.existsSync(path.join(output, `${page}${extension}`)), `Missing page file: ${page}${extension}`) + const config = JSON.parse(fs.readFileSync(path.join(output, `${page}.json`), 'utf8')) + check(config.navigationStyle === 'custom' && config.pageOrientation === 'landscape', `Native display config missing: ${page}`) + const script = fs.readFileSync(path.join(output, `${page}.js`), 'utf8') + check(!/^Page\(\{/m.test(script) && /require\(["']\.\.\/(?:\.\.\/)*utils\/tangPage\.js["']\)\(\{/.test(script), `Page lifecycle wrapper missing: ${page}`) + const style = fs.readFileSync(path.join(output, `${page}.wxss`), 'utf8') + check(/@import\s+["']\/tang-detective\/shared\.wxss["'];/.test(style), `Native style import missing: ${page}`) + } + let dependencies = 0 + for (const relative of listFiles(nativeRoot).filter(file => file.endsWith('.js'))) { + const filename = path.join(nativeRoot, relative) + const script = fs.readFileSync(filename, 'utf8') + try { new vm.Script(script, { filename: relative }) } catch (error) { errors.push(error.message) } + for (const [, specifier] of script.matchAll(/require\(['"]([^'"]+)['"]\)/g)) { + dependencies += 1 + if (!specifier.startsWith('.')) { errors.push(`Non-relative native dependency: ${relative} -> ${specifier}`); continue } + try { + const resolved = localRequire.resolve(path.resolve(path.dirname(filename), specifier)) + const dependency = path.relative(output, resolved).split(path.sep).join('/') + check(dependency.startsWith('tang-detective/'), `Dependency escaped namespace: ${relative} -> ${specifier}`) + check(packageOwner(dependency) === 'main' || packageOwner(dependency) === packageOwner(`tang-detective/${relative}`), `Illegal sibling package import: ${relative} -> ${specifier}`) + } catch (error) { errors.push(`Unresolved require: ${relative} -> ${specifier}: ${error.message}`) } + } + } + const outputSizes = { totalFileBytes: 0, mainPackageFileBytes: 0, subPackages: {} } + const outputPackages = hostApp.subPackages || hostApp.subpackages || [] + for (const relative of listFiles(output)) { + const bytes = fs.statSync(path.join(output, relative)).size + outputSizes.totalFileBytes += bytes + const subpackage = outputPackages.find(item => relative.startsWith(`${item.root}/`)) + if (subpackage) outputSizes.subPackages[subpackage.root] = (outputSizes.subPackages[subpackage.root] || 0) + bytes + else outputSizes.mainPackageFileBytes += bytes + } + return { + passed: errors.length === 0, + nativePages: manifest.allPages.length, + mediaFiles, + packagedMediaFiles, + mediaMode: remoteMode ? 'cos' : 'local', + relativeDependencies: dependencies, + nativeSizes: imported.sizes, + outputSizes, + errors, + boundary: 'Static source/asset/dependency/manifest verification only. Raw file sizes are not WeChat upload package sizes; no simulator, device, audio listening, upload, or release acceptance was performed.', + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const report = validateNativeOutput(path.resolve(projectRoot, process.argv[2] || 'dist/build/mp-weixin')) + if (process.argv[3]) fs.writeFileSync(path.resolve(projectRoot, process.argv[3]), `${JSON.stringify(report, null, 2)}\n`) + console.log(JSON.stringify(report, null, 2)) + if (!report.passed) process.exitCode = 1 +} diff --git a/TUICallKit-Vue3/config/api.js b/TUICallKit-Vue3/config/api.js new file mode 100644 index 0000000..af27863 --- /dev/null +++ b/TUICallKit-Vue3/config/api.js @@ -0,0 +1,3 @@ +// Shared by the host API client and the native Tang Detective build adapter. +// This is a public endpoint, never a place for tokens or API secrets. +export const API_BASE_URL = 'https://admin.zhenyangtang.com.cn/' diff --git a/TUICallKit-Vue3/docs/TANG-DETECTIVE-COS.md b/TUICallKit-Vue3/docs/TANG-DETECTIVE-COS.md new file mode 100644 index 0000000..7a7ec5d --- /dev/null +++ b/TUICallKit-Vue3/docs/TANG-DETECTIVE-COS.md @@ -0,0 +1,56 @@ +# 唐侦探素材 COS 迁移 + +## 当前状态(2026-09-08) + +**尚未上传,也没有切换真实 COS 地址。** 数据库端口可达,但其 TLS 自签名证书不受 Node.js 默认信任库、项目 `server/public/cacert.pem` 或本机系统信任库信任。没有关闭证书/主机名校验,没有读取到 COS 密钥,没有执行任何 COS 写操作,也没有改数据库或桶权限。 + +需提供配置数据库的可信 CA 证书文件路径,或已授权可用的 SSH 连接名;不要把数据库密码、COS 密钥或登录 token 发到聊天、清单或前端文件中。自签名证书不等于一定存在安全问题,但不能把“能连通”当成“身份已验证”。 + +473 个原字节快照文件中:160 个 JPG、44 个 MP3、0 个视频,共 204 个原路径、24,052,082 字节,按内容与扩展名去重后为 180 个对象。此次范围为实际迁入游戏的媒体,不包括源码外的草稿、试听实验或重新生成素材。原目录与 `native/tang-detective/` 均保留。 + +## 上传与校验 + +独立工具位于 `scripts/tang-cos/`,不会加入小程序运行时。使用 Node.js 22 或更新版本,在前端根目录运行: + +```sh +npm ci --prefix scripts/tang-cos --ignore-scripts --no-audit --no-fund +node scripts/tang-cos/upload.mjs inventory +node --test scripts/tang-cos/upload.test.mjs +``` + +在得到服务器管理员提供的可信 CA 文件后,将 `TANG_DB_CA_FILE` 设为该文件路径;若证书已受系统信任,可用 Node.js 的 `--use-system-ca`。不得把抓取到的陌生对端证书直接设为信任根。工具始终启用证书链和主机名校验。 + +```sh +node scripts/tang-cos/upload.mjs inspect +node scripts/tang-cos/upload.mjs upload +``` + +`inspect` 只通过只读 SQL 查询 `server/config/database.php` 配置的 `${prefix}config` 表中 `storage/default`、`storage/qcloud` 两项。仅输出目标桶、地域、公开域名和凭据存在状态,不输出数据库连接秘密或 COS 密钥。当前没有 `server/.env`;若以后新增,工具会停止而非猜测 PHP 环境解析规则,应在原生服务器运行环境中确认配置读取方式。 + +`upload` 使用腾讯官方 `cos-nodejs-sdk-v5`,只执行逐对象 HEAD/PUT,不经过会写文件表的后台上传接口。每个对象键为: + +```text +tang-detective/season-01/media-v1/<完整SHA256>.<原扩展名> +``` + +上传前核验所有快照文件;已有对象必须大小及 SHA-256 元数据相符,否则停止。PUT 使用 MD5 传输校验、SHA-256 元数据及禁止覆盖请求头,不设置 ACL、不删除对象。内容寻址降低误覆盖风险;禁止覆盖请求头不能替代已有对象检查。 + +每个对象上传后必须通过**无凭据的精确 HTTPS 地址**完整下载,核对大小、SHA-256 和媒体类型;音频/视频还检查 `Range` 分段读取。首个对象无法公开读取便停止,绝不自动把桶改为公开。此时最多留下首个已写入对象,由回执记录,工具不自动删除。 + +只有全部对象成功后才生成 `build/tang-detective-cos-manifest.json`,一一保留 204 个原路径映射。该文件不存在时构建继续使用本地媒体;不得手写虚假成功清单。逐对象上传/复用及验证证据写入 `build/tang-detective-cos-upload-receipt.json`;每次显式上传均有独立运行ID,旧回执保留到 `build/tang-detective-cos-upload-attempts/`。前置配置失败也记录为本次失败,不能借用上次成功结论。PUT 发出前持久化 `unknown`,响应不确定时做只读复查,未能确认则保持未知,不写成“确定未上传”。工具错误只输出脱敏错误码,不输出签名 URL 或原始 SDK 错误。数据库建连、查询和关闭分别有8秒、8秒、2秒截止时间,超时仅销毁本任务连接。 + +## 后续验收边界 + +本轮已执行的本地验证: + +- `npm run test:tang`:72/72通过(既有44项、远程素材13项、上传工具15项);远程测试只用独占临时目录中的模拟地址与响应。 +- 微信生产构建、H5生产构建均退出0;未启动网页预览或开发服务。 +- 实际微信产物校验通过:24页、204媒体、`mediaMode: local`。原始文件总量32,595,927字节、主包3,157,378字节,和原本地模式一致;**不能把模拟远程输出的节省量说成实际包体积已解决**。 +- 本机微信编译器检查24页WXML/WXSS通过;不等于模拟器交互或真机运行。 +- 原来源和迁入快照各473文件逐项SHA-256复核,无漂移。 +- 默认本地模式以及模拟远程模式的相对模块依赖检查均通过;远程模式覆盖120页原图哈希、112正式/8临时层级、8段第一回试听状态、断网图片最终文字回退、迟到事件、分享坏缓存和输出符号链接保护。 +- 真实COS上传、无凭据远程下载、真实音频Range和微信合法域名尚未验证;没有真实启用清单或上传回执。 + +完整下载哈希证明文件传输一致,不代表声音已获听审、商用/克隆授权、医学审核或微信真机通过。第一回内部试听与未配置的后续整页音频审核状态均不得因上传而提升。 + +完成真实上传、地址切换和构建后,仍需核对微信后台的请求/下载合法域名及对应 HTTPS 证书,实测首屏、翻页、分享、试听、后台暂停和断网回退。此次未修改微信后台配置,也未部署服务器或发布小程序。 diff --git a/TUICallKit-Vue3/docs/TANG-DETECTIVE-DEVTOOLS-CHECK-20260908.md b/TUICallKit-Vue3/docs/TANG-DETECTIVE-DEVTOOLS-CHECK-20260908.md new file mode 100644 index 0000000..34de63a --- /dev/null +++ b/TUICallKit-Vue3/docs/TANG-DETECTIVE-DEVTOOLS-CHECK-20260908.md @@ -0,0 +1,39 @@ +# 唐侦探微信开发者工具检查 + +日期:2026-09-08。结论:前端宿主可启动,但服务端阅读接口检查未通过,不能认定完整接入可用。 + +## 实际观察 + +- 微信开发者工具 RC 2.02.2607171;迁移项目运行日志标注基础库 3.17.2。 +- 当前项目路径:`/Users/dagedagededagege/Pictures/xuetang/TUICallKit-Vue3/dist/build/mp-weixin`。 +- 实际模拟器显示 `pages/index/index` 的宿主首页。 +- 控制台两次读取均可见: + - `GET https://admin.zhenyangtang.com.cn/api/tang/catalog 404` + - `GET https://admin.zhenyangtang.com.cn/api/tang/progress 404` +- 这些证据说明当时配置地址上的章节目录和阅读存档请求失败;仅凭404不能区分未部署、路由失效、代理转发或地址指向错误。没有因此修改服务器或部署代码。 + +## 操作与证据边界 + +主Agent先识别出原先打开的是 `Work/TUICallKit-Vue3/zyt-migration/...` 的旧三消项目,再经导入对话框选中本次迁移目录。在准备隔离测试副本期间,工具报告用户改变了应用状态;重新读取发现用户已将原迁移项目打开并操作。主Agent没有点击该导入对话框的“创建”,没有把用户后续操作记为自己完成的测试。 + +因此,本轮已核实的是正确项目的实际界面与失败请求;不是主Agent完成了一遍《唐侦探》玩法流程。入口、C01/C15翻页、试听播放/暂停、退出后声音停止、真机及云端保存均没有本轮完整通过证据。 + +只读源码核对发现宿主启动可能自动登录,返回学堂还可能关联健康上下文;不能直接把同AppID的新目录当作独立游客。主Agent准备过独立临时副本:原app入口字节保留,前置内存存储和业务请求阻断。代码审查在假wx环境中验证了15种替换失败均会阻止宿主执行,但副本**没有导入开发者工具运行**,不能用这项检查冒充模拟器测试或全包沙箱认证。 + +本轮没有修改产品源码、没有清理用户存储、没有主动提交健康资料或执行上传/发布。用户打开的原项目可能已自动发起其他请求;本轮没有审计其全部请求及副作用,不声称该原项目全程离线。 + +## 当时产物标识 + +| 文件 | SHA-256 | +| --- | --- | +| `app.js` | `1161a9f9dab28d3ae592e3b13bcdf3ffaf81a214e2d7e751d7003c143865e66c` | +| `app.json` | `bd6aa0028069fd2b0801681e2e62ab8faf3ddc9544ea5375e8f7db5c33e6c92e` | +| `tang-detective/.native-import-state.json` | `8518b430dbe156a19a6dd317034e8935090e7be805337ab35c29c944f5721b32` | + +## 下一步 + +先由具备权限的执行者确认当前域名的服务端部署及路由,使两个GET接口返回契约规定的响应,再在独立测试账号下验证保存接口、完整页面路径和试听。COS仍未上传,不应把当前本地媒体表现当作云端媒体通过。 + +实际专业Agent:微信开发专业Agent核对测试路线与自动写入边界;代码审查专业Agent复核临时隔离代码。未启动其他专业Agent。 + +资源:临时副本未运行,收尾删除该任务独占副本;原素材与产物保留。现有旧项目以及用户新打开的迁移项目窗口均保留,没有关闭用户工作窗口。本轮没有启动播放器、开发服务或常驻后台进程。 diff --git a/TUICallKit-Vue3/docs/TANG-DETECTIVE-INTEGRATION.md b/TUICallKit-Vue3/docs/TANG-DETECTIVE-INTEGRATION.md new file mode 100644 index 0000000..9d9fa86 --- /dev/null +++ b/TUICallKit-Vue3/docs/TANG-DETECTIVE-INTEGRATION.md @@ -0,0 +1,78 @@ +# 唐侦探接入学堂:本地实现与交接 + +日期:2026-09-08。目标仓库为 `Pictures/xuetang`,前端 `TUICallKit-Vue3`,后端 `server`。 + +已完成本地代码接入。**尚不是已部署、可上传或真机验收通过的版本**:全量素材保留后包体积仍需处理;数据库迁移、真实接口联调和真机体验未执行。 + +COS 素材迁移补充见 [TANG-DETECTIVE-COS.md](TANG-DETECTIVE-COS.md):现已核齐204个媒体原路径,但配置数据库证书验证未通过,真实上传和地址切换尚未执行。下面的本地包体积/测试数据为迁移接入基线,不应当作远程素材模式的验收结果。 + +## 入口与保留内容 + +血糖管理页 `tongji/pages/weekly.vue` 增加《唐侦探》图标卡片,与现有《识糖小课堂》三消并列。点击经 `tongji/tang-detective/index.vue` 进入原生横屏故事;游戏首页增加“返回学堂”和阅读存档状态。入口沿用宿主图标、绿色卡片和文字层级,不改三消、血糖记录、通话或登录流程。 + +保留原十五回、六个主页面、十五个章节页面、三个音频播放器,共24页及18个原生分包;图片和音频原字节复制,不生成或重新编码。 + +原来源:`/Users/dagedagededagege/Documents/Codex/2026-07-22/new-chat/wechat-miniprogram/miniprogram`。原目录未修改。473文件快照在 `native/tang-detective/`,SHA-256清单在 `build/tang-detective-source-manifest.json`。不导入旧 `app.js`、私有工程配置或凭据;原 `app.json` 仅作为构建元数据。迁移修改均在构建插件和 `native-adapter/tang-detective/` 中完成。 + +H5可构建并显示明确的微信运行提示,**没有把原生游戏转换成网页可玩版本**。原配音的人工审核和远程音频配置状态保持不变;不能把迁移当作试听、授权或发布验收。 + +## 接口与存档 + +三个独立接口使用现有 `token` 请求头: + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| GET | `/api/tang/catalog` | 十五回合法章节、事件、页码、卡片目录 | +| GET | `/api/tang/progress` | 当前登录用户的阅读存档 | +| POST | `/api/tang/saveProgress` | 修订匹配的替换/重置、幂等确认 | + +前端和宿主共用 `config/api.js` 的 `API_BASE_URL`;原生构建生成独立的只含公开地址的 `utils/platformConfig.js`。需要调试另一套服务器时修改该公开地址并重建;不要在文件内写token或密钥。当前默认地址沿用宿主配置,本轮未请求该地址。 + +存档桥仅上传白名单ID、已完成事件、当前页和修订;不上传健康回答、正文快照、音频、偏好、患者信息或账号凭据。后端新增独立用户存档表,不写三消排行榜。 + +游客记录仅留本机,不自动转到登录账号。服务端确认用户ID后隔离本机存档;token指纹只用于本机身份映射,不保存另一份token。续签同一账号可恢复其本机待同步队列。旧账号页面回调不能写入新账号存档。原版旧存储键保留、不自动导入。 + +断网保留本机进度;保存请求串行、600ms合并,超时8秒。超时重试复用原请求ID和正文。发生版本/故事代次冲突时暂停自动覆盖,首页点击存档状态选择云端或本机,确认前保留可恢复的本机备份。重置保留收藏,服务端递增故事代次。永久契约错误不在页面切换时自动重试。 + +后端完整契约、错误码和验证边界见 `../server/docs/tang-detective-progress.md`(从仓库根:`server/docs/tang-detective-progress.md`),机器契约为 `server/docs/tang-detective.openapi.yaml`。 + +## 怎样运行 + +在前端目录执行: + +```sh +npm ci --ignore-scripts --no-audit --no-fund +# 仅首次、TUICallKit尚不存在时,按项目原README准备SDK: +test -e TUICallKit || cp -R node_modules/@trtc/calls-uikit-wx-uniapp TUICallKit +test -f static/RTCCallEngine.wasm.br || cp node_modules/@trtc/call-engine-lite-wx/RTCCallEngine.wasm.br static/RTCCallEngine.wasm.br +npm run test:tang +npm run build:mp-weixin +npm run check:tang-output +node scripts/check-tang-native-compiler.cjs +``` + +原生编译检查使用本机已安装的微信开发者工具内 `wcc`/`wcsc`,不打开应用、不上传、不播放。其他机器可通过 `TANG_WECHAT_COMPILER_DIR` 指向其编译器目录。 + +微信产物为 `dist/build/mp-weixin`。使用微信开发者工具打开该目录进行后续模拟器/真机检查;本轮没有自动打开用户已有工具窗口。调试构建仍用原项目 `npm run dev:mp-weixin`,开发监听及工具窗口由启动者负责关闭。 + +后端部署前由有权限的人核对数据库前缀、备份及运行环境,再审阅 `server/sql/1.9.20260908/add_tang_detective_progress.sql`;该脚本本轮未执行。先具备表和接口,再验证真实登录同步。不要运行现有三消上报接口来验证唐侦探。 + +## 实测与缺口 + +- 微信生产构建、H5生产构建:退出码0。 +- `npm run test:tang` 共44项通过:7项导入测试、27项存档桥/基础生命周期测试、10项加载真实章节与播放器的生命周期回归;不访问真实API或数据库。 +- 产物校验:24页、204份媒体、328个相对模块依赖;473份源快照与原目录哈希无漂移。 +- 本机微信编译器:24页WXML、WXSS编译退出码0,无诊断输出。只证明本机编译语法,不证明页面交互/设备表现。 +- 后端15章、60事件、120页、15卡ID逐项核对通过;OpenAPI YAML解析通过。 +- 两份PHP契约脚本已写,因本机没有PHP运行时而**未执行**;也没有真实MySQL、HTTP中间件、并发数据库或线上联调证据。 +- 未做模拟器交互、横屏小屏布局、真机、音频试听、目标老人/医生审核、上传或公开发布。 + +独立代码审查发现并复核闭环了5项迁移问题:离线重置后的新阅读保护、卸载资源清理、旧账号确认回调隔离、队列落盘失败后的恢复、播放器延迟初始化生命周期。复核仍限本地代码与模拟平台接口,不升级为真机或服务器验收。 + +当前原始文件主包3,157,378字节、总包32,595,927字节(约3.16MB和32.60MB,非微信上传压缩体积)。精确分包数据与哈希见 `build/tang-detective-native-validation.json`。**不得直接宣称可上传**;需要后续分包/受控静态资源托管方案及真实包分析,不应靠删除章节、改画质或关闭检查掩盖问题。 + +## 回退与资源 + +要停用入口,可回退本次入口/路由/构建插件接入;不要删除原项目、用户存档或新增存档表。保留快照和适配层便于逐项比较。没有提交或推送Git,没有部署服务器。 + +本任务的构建/测试/原生编译子进程均结束;测试临时目录已清理。依赖、SDK、本地构建结果和交接文件保留。没有打开浏览器、播放器或本地服务;用户已有应用与服务未触碰。 diff --git a/TUICallKit-Vue3/native-adapter/tang-detective/pages/catalog/catalog.js b/TUICallKit-Vue3/native-adapter/tang-detective/pages/catalog/catalog.js new file mode 100644 index 0000000..d6d1ffa --- /dev/null +++ b/TUICallKit-Vue3/native-adapter/tang-detective/pages/catalog/catalog.js @@ -0,0 +1,99 @@ +const chapters = require('../../data/chapters') +const { + getProgress, + saveProgress, + resetStoryProgress, + getSettings, +} = require('../../utils/storage') +const { chapterRoute } = require('../../utils/chapterRoute') +const bridge = require('../../utils/platformBridge') + +Page({ + data: { + chapters: [], + fontScale: 'large', + catalogListEnded: false, + }, + + onShow() { + const progress = getProgress() + const settings = getSettings() + const completed = new Set(progress.completedChapters) + const lastChapter = Number(progress.lastChapter) || 1 + const completedHotspots = progress.completedHotspots || {} + this.setData({ + chapters: chapters.map((chapter) => ({ + ...chapter, + completed: completed.has(chapter.chapterId), + current: chapter.number === lastChapter, + readingLabel: completed.has(chapter.chapterId) + ? '重新翻看这一回' + : chapter.number === lastChapter + ? '上次读到这里' + : '翻开这一回', + eventCount: Array.isArray(completedHotspots[chapter.chapterId]) + ? completedHotspots[chapter.chapterId].length + : 0, + })), + lastChapter, + fontScale: settings.fontScale === 'xlarge' ? 'xlarge' : 'large', + catalogListEnded: false, + }) + }, + + markListEnd() { + if (!this.data.catalogListEnded) { + this.setData({ catalogListEnded: true }) + } + }, + + openChapter(event) { + const chapter = Number(event.currentTarget.dataset.chapter) + const progress = getProgress() + const chapterMeta = chapters.find((item) => item.number === chapter) + const replay = Boolean( + chapterMeta + && progress.completedChapters.includes(chapterMeta.chapterId), + ) + progress.lastChapter = chapter + saveProgress(progress) + wx.navigateTo({ url: chapterRoute(chapter, { replay }) }) + }, + + restartStory() { + const expectedScope = bridge.getScope() + const visibleGeneration = this.__tangShowGeneration + wx.showModal({ + title: '从第一回重新开始?', + content: '关卡进度会清空;已经收藏的“我的桂香岁月”和大字设置会保留。', + confirmText: '重新开始', + cancelText: '先不清空', + confirmColor: '#9f3028', + success: (result) => { + if (!result || !result.confirm || this.__tangDead || !this.__tangVisible + || visibleGeneration !== this.__tangShowGeneration + || expectedScope !== bridge.getScope()) return + if (!resetStoryProgress(expectedScope)) { + wx.showToast({ title: '进度暂时没有清空,请稍后再试', icon: 'none' }) + return + } + wx.redirectTo({ url: chapterRoute(1) }) + }, + }) + }, + + goBack() { + wx.reLaunch({ url: '/tang-detective/pages/home/home' }) + }, + + onShareAppMessage() { + return { + title: '唐侦探:翻开十五回桂香故事', + path: '/tang-detective/pages/catalog/catalog', + } + }, + + onShareTimeline() { + return { title: '唐侦探:翻开十五回桂香故事', query: '' } + }, +}) diff --git a/TUICallKit-Vue3/native-adapter/tang-detective/pages/home/home.js b/TUICallKit-Vue3/native-adapter/tang-detective/pages/home/home.js new file mode 100644 index 0000000..25d5da6 --- /dev/null +++ b/TUICallKit-Vue3/native-adapter/tang-detective/pages/home/home.js @@ -0,0 +1,184 @@ +const memoryCards = require('../../data/memoryCards') +const { getProgress } = require('../../utils/storage') +const { getCollectedMemories } = require('../../utils/memoryCollection') +const { chapterRoute } = require('../../utils/chapterRoute') +const bridge = require('../../utils/platformBridge') +const STATUS_TEXT = { + guest: '游客阅读 · 仅存本机', offline: '离线存档 · 点此重试', pending: '本机已保存 · 等待同步', + connecting: '正在连接存档…', syncing: '本机已保存 · 正在同步', synced: '阅读存档已同步', + conflict: '存档有冲突 · 点此选择', 'auth-expired': '登录已失效 · 本机记录保留', + 'storage-error': '本机未存成功 · 请检查空间', 'version-error': '存档版本不一致 · 仅本机阅读', + 'sync-error': '存档未同步 · 请更新后重试', +} +const { + getHomeLayoutMetrics, + getHomeLayoutStyle, + hasReadingProgress, +} = require('./homeLayout') + +Page({ + data: { + tangStatusText: '正在读取存档…', + coverOpened: false, + completedCount: 0, + lastChapter: 1, + hasReadingProgress: false, + memoryCount: 0, + homeLayoutStyle: [ + 'height:390px', + '--home-top-inset:8px', + '--home-right-inset:10px', + '--home-bottom-inset:7px', + '--home-left-inset:10px', + ].join(';'), + }, + + onLoad() { + this.unsubscribeTang = bridge.subscribe(() => this.refreshTangStatus()) + this.refreshTangStatus() + this.applyHomeLayout() + }, + + onShow() { + this.refreshHomeProgress() + this.refreshTangStatus() + }, + + onUnload() { + if (this.unsubscribeTang) this.unsubscribeTang() + }, + + refreshHomeProgress() { + const progress = getProgress() + this.setData({ + completedCount: progress.completedChapters.length, + lastChapter: progress.lastChapter || 1, + hasReadingProgress: hasReadingProgress(progress), + memoryCount: getCollectedMemories(progress, memoryCards).length, + }) + this.applyHomeLayout() + }, + + refreshTangStatus() { + this.setData({ tangStatusText: STATUS_TEXT[bridge.getStatus()] || '本机阅读存档' }) + }, + + async syncTang() { + const scope = bridge.getScope() + const visibleGeneration = this.__tangShowGeneration + const stillActive = () => !this.__tangDead && this.__tangVisible + && this.__tangShowGeneration === visibleGeneration && bridge.getScope() === scope + if (!wx.getStorageSync('token')) { + wx.showToast({ title: '请先在学堂登录,游客记录不会自动上传', icon: 'none' }) + return + } + await bridge.open(true) + if (!stillActive()) return + if (bridge.getStatus() !== 'conflict') { this.refreshHomeProgress(); return } + const conflictContext = bridge.getConflictContext() + wx.showActionSheet({ + itemList: ['采用云端存档', '用本机存档覆盖云端'], + success: result => { + if (!stillActive() || bridge.getConflictContext() !== conflictContext) return + const choice = result.tapIndex === 0 ? 'cloud' : 'local' + wx.showModal({ + title: '确认阅读存档', + content: choice === 'cloud' ? '将用云端进度替换当前本机进度。原记录会保留一份本机备份。' + : '将用本机进度覆盖云端,其他设备的未同步阅读可能不在其中。原记录会保留一份本机备份。', + confirmText: '确认使用', + success: async answer => { + if (answer.confirm && stillActive()) { + await bridge.resolveConflict(choice, conflictContext) + if (stillActive()) this.refreshHomeProgress() + } + }, + }) + }, + }) + }, + + returnToXuetang() { + bridge.flush() + wx.reLaunch({ url: '/tongji/pages/weekly' }) + }, + + onResize(resizeInfo) { + this.applyHomeLayout(resizeInfo) + }, + + applyHomeLayout(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + + const resizeSize = resizeInfo && resizeInfo.size + ? resizeInfo.size + : resizeInfo + if (resizeSize) { + windowInfo = { + ...windowInfo, + windowWidth: resizeSize.windowWidth || windowInfo.windowWidth, + windowHeight: resizeSize.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getHomeLayoutMetrics(windowInfo, menuRect) + this.setData({ + homeLayoutStyle: getHomeLayoutStyle(metrics), + }) + }, + + startGame() { + const chapter = this.data.lastChapter || 1 + wx.navigateTo({ + url: chapterRoute(chapter), + }) + }, + + revealCover() { + if (this.data.coverOpened) return + this.setData({ coverOpened: true }) + }, + + keepCoverOpen() {}, + + openCatalog() { + wx.navigateTo({ url: '/tang-detective/pages/catalog/catalog' }) + }, + + openCast() { + wx.navigateTo({ url: '/tang-detective/pages/cast/cast' }) + }, + + openMemories() { + wx.navigateTo({ url: '/tang-detective/pages/memories/memories' }) + }, + + onShareAppMessage() { + return { + title: '唐侦探:在一桌饭里,看见被忽略的人', + path: '/tang-detective/pages/home/home', + } + }, + + onShareTimeline() { + return { + title: '唐侦探:在一桌饭里,看见被忽略的人', + query: '', + } + }, +}) diff --git a/TUICallKit-Vue3/native-adapter/tang-detective/pages/home/home.wxml b/TUICallKit-Vue3/native-adapter/tang-detective/pages/home/home.wxml new file mode 100644 index 0000000..5c5135a --- /dev/null +++ b/TUICallKit-Vue3/native-adapter/tang-detective/pages/home/home.wxml @@ -0,0 +1,69 @@ + + + + + + + + + + 甄养堂 · 中国健康连环画 + + 第一季 + 唐侦探 + 桂香里的第十五桌 + + + + 翻开瞧瞧 + + + + + + + + 甄养堂 + 一本能看、能点、能带回家聊的中国健康连环画 + + + + 桂香里的第十五桌 + 第一季 · 一桌饭里的三代人 + “一桌饭,不应该只有坐下的人,还应该有被看见的人。” + + + + + + + + + + 先看画、听故事;翻到背面,再聊聊这回事。 + + + + 这里聊的是日常生活,不替代诊断、处方或个体化治疗建议。 + diff --git a/TUICallKit-Vue3/native-adapter/tang-detective/pages/home/home.wxss b/TUICallKit-Vue3/native-adapter/tang-detective/pages/home/home.wxss new file mode 100644 index 0000000..e27128f --- /dev/null +++ b/TUICallKit-Vue3/native-adapter/tang-detective/pages/home/home.wxss @@ -0,0 +1,683 @@ +@import "/tang-detective/shared.wxss"; + +.tang-host-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex: 0 0 44px; padding-right: 92px; } +.tang-host-button, .tang-sync-button { margin: 0; min-height: 44px; padding: 0 12px; line-height: 44px; font-size: 16px; border-radius: 8px; color: #f3e5bd; background: #30251c; } +.tang-sync-button { font-size: 14px; } +.home-shell { + --home-top-inset: calc(8px + constant(safe-area-inset-top)); + --home-right-inset: calc(10px + constant(safe-area-inset-right)); + --home-bottom-inset: calc(7px + constant(safe-area-inset-bottom)); + --home-left-inset: calc(10px + constant(safe-area-inset-left)); + --home-top-inset: calc(8px + env(safe-area-inset-top)); + --home-right-inset: calc(10px + env(safe-area-inset-right)); + --home-bottom-inset: calc(7px + env(safe-area-inset-bottom)); + --home-left-inset: calc(10px + env(safe-area-inset-left)); + display: flex; + height: 100vh; + min-height: 0; + padding: var(--home-top-inset) var(--home-right-inset) + var(--home-bottom-inset) var(--home-left-inset); + gap: 6px; + overflow: hidden; + flex-direction: column; +} + +.home-comic-book { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #33251b; + background: #1d130f; + border: 3px solid #9e845e; + border-radius: 10px; + box-shadow: 0 15px 34px rgba(0, 0, 0, 0.36); + grid-template-columns: minmax(0, 1fr); +} + +.home-comic-book.is-open { + grid-template-columns: minmax(0, 1.45fr) minmax(280px, 1fr); +} + +.home-cover-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #241811; +} + +.home-comic-book.is-open .home-cover-art { + border-right: 7px solid #6f2b23; + box-shadow: 10px 0 24px rgba(32, 19, 12, 0.34); +} + +.home-cover-image, +.home-cover-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.home-cover-shade { + pointer-events: none; + background: + linear-gradient(90deg, rgba(29, 18, 12, 0.68), transparent 24%, transparent 72%, rgba(29, 18, 12, 0.2)), + linear-gradient(0deg, rgba(31, 18, 12, 0.82), transparent 53%); + box-shadow: inset 0 0 70px rgba(30, 16, 9, 0.3); +} + +.home-cover-spine { + position: absolute; + z-index: 3; + top: 0; + bottom: 0; + left: 0; + display: flex; + width: 46px; + align-items: center; + justify-content: center; + color: #f0d8a7; + background: rgba(74, 35, 26, 0.94); + border-right: 2px solid #c59a58; + font-family: "Songti SC", "STSong", serif; + font-size: 15px; + font-weight: 850; + letter-spacing: 3px; + writing-mode: vertical-rl; +} + +.home-cover-title-block { + position: absolute; + z-index: 3; + left: 72px; + bottom: 54px; + display: flex; + max-width: 64%; + padding: 13px 19px 15px; + color: #f7e7c4; + background: rgba(43, 27, 18, 0.86); + border-left: 7px solid #a53a30; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.home-cover-series { + color: #e4c277; + font-size: 16px; + font-weight: 800; + letter-spacing: 4px; +} + +.home-cover-title { + margin-top: 3px; + font-family: "Songti SC", "STSong", serif; + font-size: 43px; + font-weight: 900; + letter-spacing: 8px; + line-height: 1.05; +} + +.home-cover-subtitle { + margin-top: 6px; + color: #f1d18c; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.2; +} + +.home-cover-touch { + position: absolute; + z-index: 4; + right: 24px; + bottom: 22px; + display: flex; + min-height: 50px; + align-items: center; + gap: 9px; + padding: 8px 15px; + color: #fff0ca; + background: rgba(54, 34, 23, 0.9); + border: 2px solid #d7b36d; + border-radius: 999px; + font-size: 18px; + font-weight: 850; + box-shadow: 0 7px 18px rgba(0, 0, 0, 0.28); +} + +.home-cover-touch-mark { + display: flex; + width: 31px; + height: 31px; + align-items: center; + justify-content: center; + color: #6c271f; + background: #f0d28c; + border-radius: 50%; + font-size: 28px; + line-height: 1; +} + +.home-flyleaf { + display: flex; + box-sizing: border-box; + min-width: 0; + min-height: 0; + justify-content: center; + overflow: hidden; + padding: 16px 20px; + background: + repeating-linear-gradient(0deg, rgba(92, 61, 35, 0.03) 0, rgba(92, 61, 35, 0.03) 1px, transparent 1px, transparent 5px), + #f2e4bf; + flex-direction: column; +} + +.home-flyleaf-title { + display: block; + margin-top: 9px; + color: #8d2d26; + font-family: "Songti SC", "STSong", serif; + font-size: 29px; + font-weight: 900; + line-height: 1.18; +} + +.home-flyleaf .home-quote { + position: static; + display: block; + margin-top: 10px; + color: #4e3a2b; + font-size: 18px; + font-weight: 750; + line-height: 1.45; +} + +.home-flyleaf .home-primary { + margin-top: 12px; +} + +.home-flyleaf .home-tabs { + width: 100%; + min-width: 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.home-flyleaf .home-tab { + box-sizing: border-box; + width: 100%; + min-width: 0; + overflow: hidden; + padding: 5px 7px; + font-size: 16px; + white-space: nowrap; +} + +.home-comic-book button::after { + border: 0; +} + +.home-book { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(285px, 2fr); + border: 2px solid #9e845e; + border-radius: 9px; +} + +.home-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid var(--cinnabar); +} + +.home-art-image, +.home-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.home-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(35, 23, 16, 0.82), transparent 48%); +} + +.home-memory-ribbon { + position: absolute; + z-index: 3; + top: 12px; + left: 12px; + display: flex; + min-height: 48px; + align-items: center; + gap: 7px; + padding: 6px 10px 6px 7px; + color: #4a3225; + background: rgba(241, 221, 178, 0.96); + border: 2px solid #9e754a; + border-radius: 5px; + box-shadow: 0 5px 14px rgba(25, 15, 8, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + white-space: nowrap; +} + +.home-memory-ribbon-mark { + display: flex; + width: 28px; + height: 35px; + align-items: center; + justify-content: center; + color: #ffe9bb; + background: #963128; + clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 78%, 0 100%); + font-size: 14px; + font-weight: 900; +} + +.home-quote { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + color: #f5e5c0; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 20px; + font-weight: 800; + line-height: 1.5; +} + +.home-copy { + display: flex; + box-sizing: border-box; + min-width: 0; + min-height: 0; + justify-content: center; + overflow-x: hidden; + overflow-y: auto; + padding: 18px 24px; + flex-direction: column; +} + +.home-brand { + display: flex; + align-items: center; + gap: 10px; +} + +.home-brand .seal { + width: 48px; + height: 48px; + flex: none; + font-size: 27px; +} + +.home-brand-copy { + display: flex; + color: #7f2a23; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + line-height: 1.35; +} + +.home-brand-kind { + color: #765f45; + font-size: 16px; + font-weight: 700; +} + +.home-title { + display: block; + margin-top: 10px; + font-size: 45px; + font-weight: 900; + letter-spacing: 7px; + line-height: 1.05; +} + +.home-subtitle { + display: block; + margin-top: 4px; + color: #8d2d26; + font-size: 30px; + font-weight: 900; + line-height: 1.2; +} + +.home-season { + display: block; + margin-top: 5px; + color: #6f5942; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.home-primary { + display: flex; + width: 100%; + min-height: 64px; + align-items: center; + justify-content: center; + margin-top: 16px; + padding: 8px 14px; + color: #fff0cb; + background: var(--cinnabar); + border: 3px solid #74231d; + border-radius: 8px; + box-shadow: 0 8px 18px rgba(111, 32, 27, 0.24); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 900; +} + +.home-tabs { + display: grid; + gap: 9px; + margin-top: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.home-tab { + position: relative; + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 6px 10px; + color: #4b3829; + background: #ead9b2; + border: 2px solid #997b54; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.home-memory-count { + display: flex; + min-width: 30px; + height: 26px; + align-items: center; + justify-content: center; + padding: 0 6px; + color: #f9edce; + background: #704b35; + border-radius: 999px; + font-size: 13px; +} + +.home-tagline { + display: block; + margin-top: 10px; + color: #594431; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + line-height: 1.48; +} + +.home-disclaimer { + display: block; + flex: none; + color: #d7c49b; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + line-height: 1.35; + text-align: center; +} + +@media (max-height: 620px) { + .home-copy { + justify-content: flex-start; + overflow-y: hidden; + padding: 12px 18px; + } + + .home-brand .seal { + width: 42px; + height: 42px; + font-size: 24px; + } + + .home-brand-copy { + font-size: 17px; + } + + .home-brand-kind { + font-size: 15px; + } + + .home-title { + margin-top: 7px; + font-size: 39px; + } + + .home-subtitle { + font-size: 27px; + } + + .home-season { + font-size: 17px; + } + + .home-primary { + min-height: 60px; + margin-top: 11px; + font-size: 20px; + } + + .home-tabs { + margin-top: 8px; + } + + .home-tab { + min-height: 48px; + font-size: 17px; + } + + .home-tagline { + display: block; + margin-top: 6px; + font-size: 16px; + line-height: 1.35; + } + + .home-disclaimer { + font-size: 16px; + } +} + +@media (max-width: 730px) { + .home-brand-kind { + display: none; + } + + .home-brand { + max-width: calc(100% - 104px); + } + + .home-title { + font-size: 39px; + letter-spacing: 5px; + } + + .home-subtitle { + font-size: 26px; + } + + .home-tagline { + display: block; + margin-top: 6px; + font-size: 16px; + line-height: 1.35; + } + + .home-memory-ribbon { + top: 9px; + left: 9px; + min-height: 48px; + padding: 5px 8px 5px 6px; + font-size: 16px; + } +} + +@media (max-height: 430px) { + .home-comic-book.is-open { + grid-template-columns: minmax(0, 1.3fr) minmax(292px, 1fr); + } + + .home-cover-title-block { + left: 62px; + bottom: 34px; + padding: 9px 13px 10px; + } + + .home-cover-series { + font-size: 13px; + } + + .home-cover-title { + font-size: 34px; + letter-spacing: 6px; + } + + .home-cover-subtitle { + font-size: 20px; + } + + .home-cover-touch { + right: 16px; + bottom: 13px; + min-height: 48px; + font-size: 16px; + } + + .home-flyleaf { + padding: 8px 12px; + } + + .home-flyleaf .home-brand .seal { + width: 34px; + height: 34px; + font-size: 20px; + } + + .home-flyleaf .home-brand-copy { + font-size: 14px; + } + + .home-flyleaf-title { + margin-top: 5px; + font-size: 23px; + } + + .home-flyleaf .home-season { + font-size: 14px; + } + + .home-flyleaf .home-quote { + margin-top: 5px; + font-size: 15px; + line-height: 1.3; + } + + .home-flyleaf .home-primary { + min-height: 50px; + margin-top: 6px; + font-size: 18px; + } + + .home-flyleaf .home-tabs { + gap: 5px; + margin-top: 5px; + } + + .home-flyleaf .home-tab { + min-height: 48px; + padding: 4px; + font-size: 14px; + } + + .home-flyleaf .home-tagline { + margin-top: 4px; + font-size: 14px; + } + + .home-copy { + justify-content: flex-start; + overflow-y: hidden; + padding: 8px 14px; + } + + .home-brand .seal { + width: 36px; + height: 36px; + font-size: 22px; + } + + .home-brand-copy { + font-size: 15px; + } + + .home-brand-kind { + font-size: 13px; + } + + .home-title { + margin-top: 4px; + font-size: 34px; + letter-spacing: 4px; + } + + .home-subtitle { + margin-top: 2px; + font-size: 23px; + } + + .home-season { + margin-top: 3px; + font-size: 15px; + } + + .home-primary { + min-height: 60px; + margin-top: 7px; + padding: 6px 10px; + font-size: 19px; + } + + .home-tabs { + margin-top: 5px; + } + + .home-tagline { + display: block; + margin-top: 4px; + font-size: 15px; + line-height: 1.2; + } + + .home-disclaimer { + font-size: 14px; + line-height: 1.2; + } +} diff --git a/TUICallKit-Vue3/native-adapter/tang-detective/utils/cosMedia.js b/TUICallKit-Vue3/native-adapter/tang-detective/utils/cosMedia.js new file mode 100644 index 0000000..720a88b --- /dev/null +++ b/TUICallKit-Vue3/native-adapter/tang-detective/utils/cosMedia.js @@ -0,0 +1,125 @@ +// Transport-only lookup. Review eligibility continues to belong to the story +// and release manifests; an HTTPS URL never confers audio approval. +const manifest = require('./cosMediaManifest') +const paths = Object.create(null) +const urls = Object.create(null) +const SHARE_ASSET_ID = 'image.tang.share-preview' + +for (const entry of manifest.entries) { + paths[entry.sourcePath] = entry + urls[entry.url] = entry +} + +function entryFor(value) { + if (typeof value !== 'string') return null + if (urls[value]) return urls[value] + let relative = value.replace(/^\//, '') + if (relative.startsWith(`${manifest.namespace}/`)) { + relative = relative.slice(manifest.namespace.length + 1) + } + return paths[relative] || null +} + +function resolve(value) { + const entry = entryFor(value) + return entry ? entry.url : '' +} + +function verifiedUrl(value, sha256, kind) { + const entry = urls[value] + return entry && entry.sha256 === sha256 && entry.kind === kind + ? entry.url : '' +} + +function mapMedia(value) { + if (typeof value === 'string') { + if (/^\/(?:[a-z0-9-]+\/)?(?:assets|package-[a-z0-9-]+)\/.*\.(?:jpe?g|png|webp|gif|svg|avif|mp3|wav|aac|m4a|ogg|mp4|webm|mov)$/i.test(value)) { + // Unregistered declarations remain text-only; never guess an object URL. + return resolve(value) + } + return value + } + if (Array.isArray(value)) return value.map(mapMedia) + if (!value || typeof value !== 'object') return value + const mapped = {} + Object.keys(value).forEach(key => { mapped[key] = mapMedia(value[key]) }) + return mapped +} + +function beginComicImage(page, pageData) { + const previous = page._cosComicImage + if (!previous || previous.pageId !== pageData.currentPageId) { + page._cosComicImage = { + pageId: pageData.currentPageId, + generation: (previous ? previous.generation : 0) + 1, + failed: Object.create(null), + lastPatch: null, + } + } + const state = page._cosComicImage + pageData.comicImageGeneration = state.generation + // Ordinary reader interactions re-render this page. They must not revive a + // URL that already failed or replace terminal text fallback with an image. + if (state.lastPatch) Object.assign(pageData, state.lastPatch) +} + +function isCurrentComicImageEvent(page, event) { + const state = page._cosComicImage + const dataset = event && event.currentTarget && event.currentTarget.dataset + return Boolean(state && dataset && !page.__tangDead && !page.__tangOriginalUnloaded + && page.__tangVisible !== false && page.data.comicImageSrc + && state.pageId === page.data.currentPageId + && dataset.cosPageId === state.pageId + && Number(dataset.cosImageGeneration) === state.generation + && dataset.cosImageSrc === page.data.comicImageSrc) +} + +function recordComicImageError(page, event) { + if (!isCurrentComicImageEvent(page, event)) return false + const state = page._cosComicImage + const src = page.data.comicImageSrc + if (state.failed[src]) return false + state.failed[src] = true + return true +} + +function canUseComicFallback(page, src) { + const state = page._cosComicImage + const entry = entryFor(src) + return Boolean(state && entry && entry.kind === 'image' && !state.failed[src]) +} + +function applyComicImageFallback(page, patch) { + if (page._cosComicImage) page._cosComicImage.lastPatch = { ...patch } + page.setData(patch) +} + +function prepareSharePreview(page, assetManager) { + if (page._sharePreviewPromise) return page._sharePreviewPromise + const generation = page.__tangShowGeneration + const alive = () => !page.__tangDead && !page.__tangOriginalUnloaded + && page.__tangVisible !== false && page.__tangShowGeneration === generation + // Resolve again for each share; the manager rehashes cached bytes before use. + const promise = Promise.resolve().then(() => assetManager.resolve(SHARE_ASSET_ID)) + .then(result => { + if (!alive()) return '' + const localPath = result && result.available && result.uri || '' + page.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + .catch(() => { + if (alive()) page.setData({ sharePreviewLocalPath: '' }) + return '' + }) + .finally(() => { + if (page._sharePreviewPromise === promise) page._sharePreviewPromise = null + }) + page._sharePreviewPromise = promise + return promise +} + +module.exports = { + SHARE_ASSET_ID, entryFor, resolve, verifiedUrl, mapMedia, prepareSharePreview, + beginComicImage, isCurrentComicImageEvent, recordComicImageError, + canUseComicFallback, applyComicImageFallback, +} diff --git a/TUICallKit-Vue3/native-adapter/tang-detective/utils/identityHash.js b/TUICallKit-Vue3/native-adapter/tang-detective/utils/identityHash.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native-adapter/tang-detective/utils/identityHash.js @@ -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, +} diff --git a/TUICallKit-Vue3/native-adapter/tang-detective/utils/platformBridge.js b/TUICallKit-Vue3/native-adapter/tang-detective/utils/platformBridge.js new file mode 100644 index 0000000..f91e422 --- /dev/null +++ b/TUICallKit-Vue3/native-adapter/tang-detective/utils/platformBridge.js @@ -0,0 +1,3 @@ +const { createPlatformBridge } = require('./platformCore') +const config = require('./platformConfig') +module.exports = createPlatformBridge(wx, config) diff --git a/TUICallKit-Vue3/native-adapter/tang-detective/utils/platformCore.js b/TUICallKit-Vue3/native-adapter/tang-detective/utils/platformCore.js new file mode 100644 index 0000000..2baab07 --- /dev/null +++ b/TUICallKit-Vue3/native-adapter/tang-detective/utils/platformCore.js @@ -0,0 +1,218 @@ +const { sha256Hex } = require('./identityHash') +const { CONTENT_VERSION, emptyProgress, projectProgress } = require('./progressContract') +const copy = value => JSON.parse(JSON.stringify(value)) +const KEY = 'tang-xuetang-v1:' + +// Dependency-injected for offline tests. Tokens remain in the existing host key +// and request header only; local slots use a SHA-256 fingerprint, not the token. +function createPlatformBridge(platform, config) { + let active = null + const listeners = new Set() + function token() { try { return String(platform.getStorageSync('token') || '') } catch (_) { return '' } } + function read(key, fallback) { try { return platform.getStorageSync(key) || fallback } catch (_) { return fallback } } + function write(key, value) { try { platform.setStorageSync(key, copy(value)); return true } catch (_) { return false } } + function context() { + const current = token() + if (active && active.token === current) return active + if (active && active.timer) clearTimeout(active.timer) + const scope = KEY + (current ? sha256Hex(current) : 'guest') + const mapped = current ? read(scope + ':user', null) : null + const key = Number.isSafeInteger(mapped) && mapped > 0 ? KEY + 'user:' + mapped : scope + const cached = read(key, {}) + active = { token: current, scope, key, verified: false, opening: null, flushing: null, timer: null, + status: current ? 'offline' : 'guest', remote: null, + state: { progress: emptyProgress(), revision: 0, story_generation: 0, user_id: null, + dirty: false, resetPending: false, serial: 0, pending: null, ...cached } } + return active + } + const current = c => active === c && token() === c.token + function notify(c, status) { + if (!current(c)) return + c.status = status + listeners.forEach(listener => { try { listener(status) } catch (_) {} }) + } + function persist(c) { + const ok = write(c.key, c.state) + if (!ok) notify(c, 'storage-error') + return ok + } + async function request(c, path, method = 'GET', data) { + if (!current(c) || !c.token) throw new Error('SESSION_CHANGED') + return new Promise((resolve, reject) => platform.request({ + url: String(config.apiBaseUrl).replace(/\/+$/, '') + '/api/tang/' + path, + method, data, timeout: 8000, + header: { token: c.token, 'content-type': 'application/json' }, + success(result) { + if (!current(c)) return reject(new Error('SESSION_CHANGED')) + const body = result.data + if (result.statusCode !== 200 || !body || typeof body !== 'object') return reject(new Error('API_UNAVAILABLE')) + if (body.code === -1) { c.verified = false; notify(c, 'auth-expired'); return reject(new Error('AUTH_EXPIRED')) } + if (body.code !== 1) { + const error = new Error(body.data && body.data.error_code || 'API_UNAVAILABLE') + return reject(error) + } + resolve(body.data) + }, fail() { reject(new Error('NETWORK_UNAVAILABLE')) }, + })) + } + function validateRemote(value) { + if (!value || value.schema_version !== 1 || value.content_version !== CONTENT_VERSION + || !Number.isSafeInteger(value.user_id) || value.user_id <= 0 + || !Number.isSafeInteger(value.revision) || value.revision < 0 + || !Number.isSafeInteger(value.story_generation) || value.story_generation < 0 + || !value.progress || typeof value.progress !== 'object') throw new Error('CONTENT_MISMATCH') + return { ...value, progress: projectProgress(value.progress) } + } + function hydrate(c, remote) { + c.state.progress = { ...c.state.progress, ...remote.progress } + c.state.revision = remote.revision + c.state.story_generation = remote.story_generation + c.state.user_id = remote.user_id + c.state.pending = null + c.state.resetPending = false + c.state.dirty = false + const saved = persist(c) + if (saved) notify(c, 'synced') + return saved + } + function failure(c, error) { + if (!current(c) || error.message === 'SESSION_CHANGED') return + if (['AUTH_EXPIRED', 'AUTH_REQUIRED'].includes(error.message)) c.verified = false + const permanent = ['INVALID_REQUEST', 'PAYLOAD_TOO_LARGE', 'IDEMPOTENCY_CONFLICT', 'UNSUPPORTED_MEDIA_TYPE', 'METHOD_NOT_ALLOWED'] + if (c.status !== 'storage-error') notify(c, ['AUTH_EXPIRED', 'AUTH_REQUIRED'].includes(error.message) ? 'auth-expired' + : ['CONTENT_MISMATCH', 'UNSUPPORTED_CONTENT_VERSION'].includes(error.message) ? 'version-error' + : permanent.includes(error.message) ? 'sync-error' : 'offline') + } + async function open(force = false) { + const c = context() + if (!c.token) return c.status + if (c.opening) return c.opening + if (c.verified && !force) return c.status + c.opening = (async () => { + c.verified = false + notify(c, 'connecting') + try { + const [catalog, raw] = await Promise.all([request(c, 'catalog'), request(c, 'progress')]) + if (!current(c)) return 'session-changed' + if (!catalog || catalog.schema_version !== 1 || catalog.content_version !== CONTENT_VERSION) throw new Error('CONTENT_MISMATCH') + const remote = validateRemote(raw) + if (c.state.user_id && c.state.user_id !== remote.user_id) throw new Error('CONTENT_MISMATCH') + // Only the authenticated server identity may select a shared user slot. + // A renewed token can recover the same user's unsynced local queue. + const userKey = KEY + 'user:' + remote.user_id + if (c.key !== userKey) { + const candidate = read(userKey, null) + const saved = candidate && typeof candidate === 'object' && !Array.isArray(candidate) ? candidate : null + if (saved && !c.state.dirty) c.state = saved + else if (saved && c.state.dirty && !write(c.scope + ':previous-user-backup', saved)) throw new Error('STORAGE_UNAVAILABLE') + c.key = userKey + if (!write(c.scope + ':user', remote.user_id)) { notify(c, 'storage-error'); return c.status } + } + c.verified = true + c.state.user_id = remote.user_id + if (!c.state.dirty) hydrate(c, remote) + else if (c.state.pending) await flushContext(c) // retry the exact idempotent request first + else if (c.state.revision !== remote.revision || c.state.story_generation !== remote.story_generation) { + c.remote = remote; notify(c, 'conflict') + } else await flushContext(c) + } catch (error) { failure(c, error) } + finally { c.opening = null } + return c.status + })() + return c.opening + } + function schedule(c) { + if (c.timer) clearTimeout(c.timer) + c.timer = setTimeout(() => { c.timer = null; flushContext(c) }, 600) + } + async function flushContext(c) { + if (!current(c) || !c.verified || !c.state.dirty || ['conflict', 'storage-error', 'sync-error', 'version-error'].includes(c.status)) return + if (c.flushing) return c.flushing + // Defer preparation so the promise is assigned before any early exit. + // The outer finally also covers a failed durable queue write. + c.flushing = Promise.resolve().then(async () => { + try { + if (!current(c)) return + if (!c.state.pending) { + const operation = c.state.resetPending ? 'reset_story' : 'replace' + const progress = projectProgress(c.state.progress) + c.state.pending = { serial: operation === 'reset_story' ? c.state.resetAt : c.state.serial, body: { + schema_version: 1, content_version: CONTENT_VERSION, + base_revision: c.state.revision, story_generation: c.state.story_generation, + request_id: 'tang-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 14), + operation, progress: operation === 'reset_story' + ? { ...emptyProgress(), collectedMemoryCards: progress.collectedMemoryCards } : progress, + } } + } + if (!persist(c)) return + const pending = copy(c.state.pending) + notify(c, 'syncing') + const remote = validateRemote(await request(c, 'saveProgress', 'POST', pending.body)) + if (!current(c)) return + if (remote.user_id !== c.state.user_id) throw new Error('CONTENT_MISMATCH') + c.state.revision = remote.revision + c.state.story_generation = remote.story_generation + c.state.pending = null + // A reset created while another request was in flight must not be lost. + if (pending.body.operation === 'reset_story' && (c.state.resetAt || 0) <= pending.serial) c.state.resetPending = false + if (pending.serial === c.state.serial) hydrate(c, remote) + else if (persist(c)) { notify(c, 'pending'); schedule(c) } + } catch (error) { + if (error.message === 'PROGRESS_CONFLICT' && current(c)) { + try { c.remote = validateRemote(await request(c, 'progress')); notify(c, 'conflict') } + catch (readError) { failure(c, readError) } + } else failure(c, error) + } + }).finally(() => { c.flushing = null }) + return c.flushing + } + function saveProgress(value, reset = false) { + const c = context() + // A delayed callback from an old page/account must not enter the new slot. + if (!value || value.__tangLocalScope !== c.scope) return false + c.state.progress = copy(value) + delete c.state.progress.__tangLocalScope + c.state.dirty = true + c.state.serial += 1 + if (reset) { c.state.resetPending = true; c.state.resetAt = c.state.serial } + if (!persist(c)) return false + if (!['conflict', 'sync-error', 'version-error', 'auth-expired'].includes(c.status)) notify(c, c.token ? 'pending' : 'guest') + if (c.verified) schedule(c) + return true + } + function getConflictContext() { + const c = context() + return c.status === 'conflict' && c.remote ? JSON.stringify([ + c.scope, c.remote.revision, c.remote.story_generation, c.state.serial, + ]) : '' + } + async function resolveConflict(choice, expectedContext) { + const c = context() + if (!expectedContext || expectedContext !== getConflictContext() || !c.verified) return false + // Recoverable, account-scoped backup before either explicit resolution. + if (!write(c.key + ':conflict-backup', { local: c.state, remote: c.remote })) { notify(c, 'storage-error'); return false } + if (choice === 'cloud') { const saved = hydrate(c, c.remote); if (saved) c.remote = null; return saved } + if (choice !== 'local') return false + c.state.revision = c.remote.revision + c.state.story_generation = c.remote.story_generation + c.state.pending = null + c.remote = null + if (!persist(c)) return false + notify(c, 'pending') + await flushContext(c) + return c.status === 'synced' + } + return { + open, saveProgress, resolveConflict, getConflictContext, + getProgress: () => { const c = context(); return { ...copy(c.state.progress || {}), ...projectProgress(c.state.progress), __tangLocalScope: c.scope } }, + getScope: () => context().scope, + getStatus: () => context().status, + flush: () => { const c = context(); if (c.timer) { clearTimeout(c.timer); c.timer = null } return flushContext(c) }, + subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener) }, + // Local preferences/audio are account-scoped and are never passed to request(). + readLocal: (suffix, fallback) => copy(read(context().key + ':' + suffix, fallback)), + writeLocal: (suffix, value) => write(context().key + ':' + suffix, value), + dispose() { if (active && active.timer) clearTimeout(active.timer); listeners.clear(); active = null }, + } +} +module.exports = { createPlatformBridge } diff --git a/TUICallKit-Vue3/native-adapter/tang-detective/utils/progressContract.js b/TUICallKit-Vue3/native-adapter/tang-detective/utils/progressContract.js new file mode 100644 index 0000000..35cc346 --- /dev/null +++ b/TUICallKit-Vue3/native-adapter/tang-detective/utils/progressContract.js @@ -0,0 +1,41 @@ +// Wire projection only: never send snapshots, answers, settings or audio state. +const CONTENT_VERSION = 'season-01' +const pad = value => String(value).padStart(2, '0') +const record = value => value && typeof value === 'object' && !Array.isArray(value) ? value : {} +const list = value => Array.isArray(value) ? value : [] +function emptyProgress() { + return { completedHotspots: {}, completedChapters: [], lastChapter: 1, + collectedMemoryCards: [], comicReaderByChapter: {}, lastPageId: '' } +} +function projectProgress(input) { + const value = record(input) + const result = emptyProgress() + result.lastChapter = Number.isInteger(value.lastChapter) && value.lastChapter >= 1 && value.lastChapter <= 15 ? value.lastChapter : 1 + for (let n = 1; n <= 15; n++) { + const id = `S01-C${pad(n)}` + const saved = record(record(value.comicReaderByChapter)[id]) + const supplied = new Set(list(saved.completedEventIds || record(value.completedHotspots)[id])) + const events = [] + for (let i = 1; i <= 4; i++) { + const event = `S01-H${pad((n - 1) * 4 + i)}` + if (!supplied.has(event)) break + events.push(event) + } + const finished = events.length === 4 && (typeof saved.chapterFinished === 'boolean' + ? saved.chapterFinished : list(value.completedChapters).includes(id)) + if (Object.keys(saved).length || events.length || Object.prototype.hasOwnProperty.call(record(value.completedHotspots), id)) { + const requested = String(saved.currentPageId || (n === result.lastChapter ? value.lastPageId : '') || '') + const match = requested.match(new RegExp(`^${id}-P(0[1-8])$`)) + const page = Math.max(1, Math.min(finished ? 8 : 3 + events.length, match ? Number(match[1]) : 1)) + const currentPageId = `${id}-P${pad(page)}` + result.completedHotspots[id] = events + result.comicReaderByChapter[id] = { currentPageId, completedEventIds: events.slice(), chapterFinished: finished } + if (finished) result.completedChapters.push(id) + if (n === result.lastChapter) result.lastPageId = currentPageId + } + const card = `${id}-MC01` + if (list(value.collectedMemoryCards).includes(card)) result.collectedMemoryCards.push(card) + } + return result +} +module.exports = { CONTENT_VERSION, emptyProgress, projectProgress } diff --git a/TUICallKit-Vue3/native-adapter/tang-detective/utils/storage.js b/TUICallKit-Vue3/native-adapter/tang-detective/utils/storage.js new file mode 100644 index 0000000..602b522 --- /dev/null +++ b/TUICallKit-Vue3/native-adapter/tang-detective/utils/storage.js @@ -0,0 +1,22 @@ +const bridge = require('./platformBridge') +const { emptyProgress } = require('./progressContract') +function normalizeSettings(value = {}) { + return { ...value, fontScale: value.fontScale === 'xlarge' ? 'xlarge' : 'large', sound: value.sound !== false } +} +function resetStoryProgress(expectedScope) { + if (!expectedScope || bridge.getScope() !== expectedScope) return false + const current = bridge.getProgress() + const next = { ...current, ...emptyProgress(), collectedMemoryCards: current.collectedMemoryCards || [] } + if (!bridge.saveProgress(next, true)) return false + bridge.writeLocal('audio', {}) + return true +} +module.exports = { + getProgress: bridge.getProgress, + saveProgress: value => bridge.saveProgress(value), + resetStoryProgress, + getSettings: () => normalizeSettings(bridge.readLocal('settings', {})), + saveSettings: value => bridge.writeLocal('settings', normalizeSettings(value)), + getAudioProgress: () => bridge.readLocal('audio', {}), + saveAudioProgress: value => bridge.writeLocal('audio', value), +} diff --git a/TUICallKit-Vue3/native-adapter/tang-detective/utils/tangPage.js b/TUICallKit-Vue3/native-adapter/tang-detective/utils/tangPage.js new file mode 100644 index 0000000..170d7af --- /dev/null +++ b/TUICallKit-Vue3/native-adapter/tang-detective/utils/tangPage.js @@ -0,0 +1,145 @@ +// Keep the native Page lifecycle, but hydrate the correct account before any +// story page reads/writes storage. This also covers cold starts from shares. +const bridge = require('./platformBridge') +const LIFECYCLE_METHODS = new Set(['onLoad', 'onShow', 'onReady', 'onHide', 'onUnload']) +const CLEANUP_METHOD = /^(?:destroy|clear|unbind|pause|invalidate|beginPauseLock)/ +module.exports = function registerTangPage(options) { + function isCurrent(page) { + return !page.__tangDead && !page.__tangOriginalUnloaded + && page.__tangVisible && page.__tangScope === bridge.getScope() + } + function returnHome(page) { + if (page.__tangRedirected || page.__tangDead) return + page.__tangRedirected = true + wx.reLaunch({ url: '/tang-detective/pages/home/home' }) + } + function cleanUp(page, name) { + if (!page.__tangLoaded || typeof options[name] !== 'function') return + if (name !== 'onUnload' && page.__tangOriginalUnloaded) return + if (name === 'onUnload') { + if (page.__tangOriginalUnloaded) return + page.__tangOriginalUnloaded = true + } + page.__tangCleaning = true + try { + options[name].call(page) + } finally { + // This exception is synchronous only; pending timers and audio callbacks + // regain the normal visible/account/dead guards as soon as cleanup ends. + page.__tangCleaning = false + } + } + function deliverReady(page) { + if (!isCurrent(page) || !page.__tangLoaded || !page.__tangHasShown + || !page.__tangReadyRequested || page.__tangReadyDelivered) return + page.__tangReadyDelivered = true + if (typeof options.onReady === 'function') options.onReady.call(page) + } + const guarded = { ...options } + Object.keys(options).forEach(key => { + // Event handlers such as onAudioTimeUpdate are ordinary guarded methods, + // even though their names begin with "on". + if (typeof options[key] !== 'function' || LIFECYCLE_METHODS.has(key)) return + guarded[key] = function (...args) { + if (this.__tangCleaning) { + // Release resources across an account change, but do not run helpers + // such as saveCurrentAudioTime against the newly selected account. + if (this.__tangScope === bridge.getScope() || CLEANUP_METHOD.test(key)) { + return options[key].apply(this, args) + } + return + } + if (this.__tangDead || this.__tangOriginalUnloaded || !this.__tangLoaded || !this.__tangVisible) return + if (this.__tangScope && this.__tangScope !== bridge.getScope()) { + returnHome(this) + return + } + return options[key].apply(this, args) + } + }) + Page({ + ...guarded, + data: { ...(options.data || {}), tangBootPending: true }, + tangIgnoreBootTap() {}, + onLoad(query) { + this.__tangQuery = query + this.__tangScope = bridge.getScope() + this.__tangDead = false + this.__tangVisible = false + this.__tangLoaded = false + this.__tangCleaning = false + this.__tangOriginalUnloaded = false + this.__tangRedirected = false + this.__tangShowGeneration = 0 + this.__tangReadyRequested = false + this.__tangReadyDelivered = false + this.__tangHasShown = false + const nativeSetData = this.setData + this.setData = function (...args) { + if (this.__tangScope !== bridge.getScope()) return + if (!this.__tangCleaning && !isCurrent(this)) return + return nativeSetData.apply(this, args) + } + this.__tangBoot = bridge.open() + }, + onShow() { + if (this.__tangDead) return + this.__tangVisible = true + const generation = ++this.__tangShowGeneration + const scope = bridge.getScope() + if (this.__tangOriginalUnloaded || scope !== this.__tangScope) { + returnHome(this) + return + } + Promise.resolve(this.__tangBoot).then(() => { + if (this.__tangDead || !this.__tangVisible || generation !== this.__tangShowGeneration) return + if (bridge.getScope() !== scope) { + returnHome(this) + return + } + if (!this.__tangLoaded) { + this.__tangLoaded = true + if (options.onLoad) options.onLoad.call(this, this.__tangQuery) + if (!isCurrent(this)) return + this.setData({ tangBootPending: false }) + } + if (options.onShow) options.onShow.call(this) + this.__tangHasShown = true + deliverReady(this) + }).catch(() => { + if (isCurrent(this) && generation === this.__tangShowGeneration) { + wx.showToast({ title: '故事暂时未能打开,请返回重试', icon: 'none' }) + } + }) + }, + onReady() { + this.__tangReadyRequested = true + deliverReady(this) + }, + onHide() { + this.__tangVisible = false + ++this.__tangShowGeneration + try { + cleanUp(this, 'onHide') + } finally { + // A hidden page from an obsolete account must not retain contexts with + // callbacks that directly reference storage instead of guarded methods. + try { + if (this.__tangScope !== bridge.getScope()) cleanUp(this, 'onUnload') + } finally { + bridge.flush() + } + } + }, + onUnload() { + this.__tangDead = true + this.__tangVisible = false + ++this.__tangShowGeneration + try { + cleanUp(this, 'onUnload') + } finally { + bridge.flush() + } + }, + }) +} diff --git a/TUICallKit-Vue3/native/tang-detective/app.json b/TUICallKit-Vue3/native/tang-detective/app.json new file mode 100644 index 0000000..6cca711 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/app.json @@ -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" +} diff --git a/TUICallKit-Vue3/native/tang-detective/app.wxss b/TUICallKit-Vue3/native/tang-detective/app.wxss new file mode 100644 index 0000000..99126f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/app.wxss @@ -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; + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/assets/characters/female-cook.jpg b/TUICallKit-Vue3/native/tang-detective/assets/characters/female-cook.jpg new file mode 100644 index 0000000..95ba1e3 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/characters/female-cook.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/characters/lele.jpg b/TUICallKit-Vue3/native/tang-detective/assets/characters/lele.jpg new file mode 100644 index 0000000..d9fed81 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/characters/lele.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/characters/lin-xiulan.jpg b/TUICallKit-Vue3/native/tang-detective/assets/characters/lin-xiulan.jpg new file mode 100644 index 0000000..6cc7285 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/characters/lin-xiulan.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/characters/qin-xiaoman.jpg b/TUICallKit-Vue3/native/tang-detective/assets/characters/qin-xiaoman.jpg new file mode 100644 index 0000000..a545921 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/characters/qin-xiaoman.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/characters/qin-zhicheng.jpg b/TUICallKit-Vue3/native/tang-detective/assets/characters/qin-zhicheng.jpg new file mode 100644 index 0000000..46a46c5 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/characters/qin-zhicheng.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/characters/tang-mingyuan.jpg b/TUICallKit-Vue3/native/tang-detective/assets/characters/tang-mingyuan.jpg new file mode 100644 index 0000000..1067e7e Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/characters/tang-mingyuan.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/characters/tang-shouan.jpg b/TUICallKit-Vue3/native/tang-detective/assets/characters/tang-shouan.jpg new file mode 100644 index 0000000..26d1225 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/characters/tang-shouan.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/characters/xiaozhen.jpg b/TUICallKit-Vue3/native/tang-detective/assets/characters/xiaozhen.jpg new file mode 100644 index 0000000..a412653 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/characters/xiaozhen.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/characters/zhao-jianguo.jpg b/TUICallKit-Vue3/native/tang-detective/assets/characters/zhao-jianguo.jpg new file mode 100644 index 0000000..42619b8 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/characters/zhao-jianguo.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg new file mode 100644 index 0000000..2e8b92e Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-1978.jpg b/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-1978.jpg new file mode 100644 index 0000000..82dfa7a Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-1978.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-1995.jpg b/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-1995.jpg new file mode 100644 index 0000000..98154ac Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-1995.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-1998.jpg b/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-1998.jpg new file mode 100644 index 0000000..0e21f4d Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-1998.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-2001.jpg b/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-2001.jpg new file mode 100644 index 0000000..4db4234 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-2001.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-2003.jpg b/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-2003.jpg new file mode 100644 index 0000000..239adda Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-2003.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-2008.jpg b/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-2008.jpg new file mode 100644 index 0000000..16da0e4 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-2008.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-2026.jpg b/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-2026.jpg new file mode 100644 index 0000000..2e76e19 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/scenes/gui-xiang-2026.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/assets/share/guixiang-story-share-preview-v1.jpg b/TUICallKit-Vue3/native/tang-detective/assets/share/guixiang-story-share-preview-v1.jpg new file mode 100644 index 0000000..836160b Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/assets/share/guixiang-story-share-preview-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/data/cast.js b/TUICallKit-Vue3/native/tang-detective/data/cast.js new file mode 100644 index 0000000..c53e318 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/data/cast.js @@ -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: '灰绿色开衫、白衬衫、低马尾与记录夹;不作广告式主角。', + }, +] diff --git a/TUICallKit-Vue3/native/tang-detective/data/chapters.js b/TUICallKit-Vue3/native/tang-detective/data/chapters.js new file mode 100644 index 0000000..c58828b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/data/chapters.js @@ -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', +})) diff --git a/TUICallKit-Vue3/native/tang-detective/data/memoryCards.js b/TUICallKit-Vue3/native/tang-detective/data/memoryCards.js new file mode 100644 index 0000000..c35a53b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/data/memoryCards.js @@ -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: '缺口搪瓷碗与第十五桌铜牌', + }, +] diff --git a/TUICallKit-Vue3/native/tang-detective/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/data/productionComicPages.js new file mode 100644 index 0000000..c191ba8 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/data/productionComicPages.js @@ -0,0 +1,2067 @@ +// Generated from the reviewed C04 and C06-C15 production storyboards. 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": "" + } + ] + }, + "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": "第一桌社会客人进门,指着靠门的第十五桌:“这张给我们拼个大桌。”" + } + ] + }, + "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": "三年后的电话问:“五月初八,十桌婚宴,敢不敢接?”秦师傅看向靠墙的第十五桌。" + } + ] + }, + "S01-C08": { + "pages": [ + { + "pageId": "S01-C08-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P01-title-v1.jpg", + "caption": "第一场婚宴热闹开席,第十五桌却在掌声里被推向后厨。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MT000", + "S01-C08-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P02-opening-ensemble-v1.jpg", + "caption": "主家怕桌上不够体面,秀兰翻看复写菜单;女炊事员端着饭盒,秦师傅正挪员工桌。", + "secondaryCaption": "", + "interactionPrompt": "先看看:谁在顾客桌的体面,谁正失去坐下吃饭的位置。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MS001", + "S01-C08-MS002", + "S01-C08-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P03", + "type": "event", + "eventId": "S01-H29", + "emotionMomentId": "", + "assetName": "S01-C08-P03-H29-extra-dishes-v1.jpg", + "caption": "菜还没上齐,转盘已经没有落筷子的空;主家仍怕显得小气,隔着满桌招手加菜。", + "secondaryCaption": "", + "interactionPrompt": "只因怕被说小气,再增加几道没人需要的菜,这样妥当吗?", + "shortDialogue": "主家说:“四凉八热才像样,再添两个硬菜。”林秀兰问:“先看看十个人吃到哪儿了?”", + "hotspot": { + "x": 27, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS002", + "S01-C08-MS003", + "S01-C08-MS004", + "S01-C08-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P04", + "type": "event", + "eventId": "S01-H30", + "emotionMomentId": "", + "assetName": "S01-C08-P04-H30-rejected-box-v1.jpg", + "caption": "宾客渐散,桌上还剩半桌;主家只因觉得没面子,把女炊事员递来的打包盒推回。", + "secondaryCaption": "", + "interactionPrompt": "只因觉得打包没面子,就拒绝带走适合安全保存的剩菜,这样妥当吗?", + "shortDialogue": "主家说:“喜事哪能拎剩菜走。”女炊事员答:“先问哪些能带,别让面子替食物做决定。”", + "hotspot": { + "x": 28, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P05", + "type": "event", + "eventId": "S01-H31", + "emotionMomentId": "", + "assetName": "S01-C08-P05-H31-menu-gaps-v1.jpg", + "caption": "秀兰把复写菜单翻过来,纸上只有菜名,没有人数,也没有一盘大约够几个人。", + "secondaryCaption": "", + "interactionPrompt": "套餐只列菜名,不说明大致份量和供几人,这样妥当吗?", + "shortDialogue": "林秀兰说:“光有菜名,不知道多大盘,客人怎么知道够不够?”", + "hotspot": { + "x": 22, + "y": 0, + "w": 61, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS008", + "S01-C08-MS009", + "S01-C08-MS010" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P06", + "type": "event", + "eventId": "S01-H32", + "emotionMomentId": "", + "assetName": "S01-C08-P06-H32-removed-plaque-v1.jpg", + "caption": "客桌加了,员工的座位却没了。秦师傅说:“就借这一晚。”", + "secondaryCaption": "", + "interactionPrompt": "为临时加桌,让员工整晚端碗站着吃,并说“就借这一晚”,这样妥当吗?", + "shortDialogue": "秦师傅说:“头一场席,就借一晚。”林秀兰停了一拍:“我也说过,就这一场。”", + "hotspot": { + "x": 23, + "y": 5, + "w": 44, + "h": 90 + }, + "audioCueIds": [ + "S01-C08-MS006", + "S01-C08-MS007", + "S01-C08-MS012", + "S01-C08-MS013" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM08", + "assetName": "S01-C08-P07-EM08-back-kitchen-light-v1.jpg", + "caption": "婚宴笑声还没散,女炊事员端着饭盒站在传菜口,一时找不到放碗的位置。", + "secondaryCaption": "", + "interactionPrompt": "在最忙的时候,你想怎样让她知道自己没有被忘记?", + "shortDialogue": "", + "hotspot": { + "x": 27, + "y": 0, + "w": 43, + "h": 100 + }, + "audioCueIds": [ + "S01-C08-MS006", + "S01-C08-MS007", + "S01-C08-TE900" + ], + "tableEcho": "热闹照得到客人,也该照得到端菜的人。", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg", + "caption": "热闹照得到客人,也该照得到端菜的人。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MS012", + "S01-C08-MS013", + "S01-C08-TE900", + "S01-C08-MS014" + ], + "tableEcho": "热闹照得到客人,也该照得到端菜的人。", + "cliffhanger": "现金盒合上前,铜牌背面的暗红漆与两张破损饭票一闪而过;下一场订席电话又响了。" + } + ] + }, + "S01-C09": { + "pages": [ + { + "pageId": "S01-C09-P01", + "type": "chapter-title-and-opening-memory", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P01-opening-memory-v1.jpg", + "caption": "医院门刚合上,第一次聚餐,一盘菜从赵伯面前悄悄挪远。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "programDetailInset": { + "label": "桌边近景", + "x": 69, + "y": 51, + "w": 31, + "h": 35, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C09-MT000", + "S01-C09-MOM001", + "S01-C09-MOM002", + "S01-C09-MOM003", + "S01-C09-MTRANS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P02-opening-ensemble-v1.jpg", + "caption": "第一圈酒绕来,赵伯坐在原位看着;老周抬起手,唐守安摸向空杯,明远的酒壶停在桌中。", + "secondaryCaption": "", + "interactionPrompt": "先看看:谁在说自己的选择,谁还在替别人决定。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C09-MTRANS001", + "S01-C09-MS004", + "S01-C09-MS006", + "S01-C09-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P03", + "type": "event", + "eventId": "S01-H33", + "emotionMomentId": "", + "assetName": "S01-C09-P03-H33-driver-no-alcohol-v1.jpg", + "caption": "老周把车钥匙压在桌上,白酒仍被推来;他用整只手挡住杯子,一口也不碰。", + "secondaryCaption": "", + "interactionPrompt": "对要开车的人说“就一小口没事”,这样妥当吗?", + "shortDialogue": "老周按住钥匙:“车是我开,一口也不碰。”林秀兰把白水换到他面前。", + "hotspot": { + "x": 20, + "y": 0, + "w": 65, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS002", + "S01-C09-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P04", + "type": "event", + "eventId": "S01-H34", + "emotionMomentId": "", + "assetName": "S01-C09-P04-H34-tea-toast-v1.jpg", + "caption": "唐守安坐在靠门末席,轻盖空酒杯,端起茶;桌上的笑声一停,没人再把酒推回来。", + "secondaryCaption": "", + "interactionPrompt": "清楚拒绝饮酒,其他人也不继续起哄,这样更稳妥吗?", + "shortDialogue": "唐守安说:“心意我领,酒不喝。咱们拿茶照样碰。”", + "hotspot": { + "x": 21, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS006", + "S01-C09-MS007", + "S01-C09-MS009", + "S01-C09-MS013" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P05", + "type": "event", + "eventId": "S01-H35", + "emotionMomentId": "", + "assetName": "S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg", + "caption": "赵伯把自己的闭合随身药盒推向一旁,想为这场酒局把原来的安排往后挪。唐守安先请他停一下。", + "secondaryCaption": "涉及用药调整,应按专业人员确认的个人方案处理;不要自行停药或改量", + "outsideActionLabel": "打开赵伯这一刻", + "artTapHint": "画中赵伯也能点", + "interactionPrompt": "为了参加酒局,自行停药、补服、加倍、减量或调整胰岛素,这样妥当吗?", + "shortDialogue": "赵伯说:“今天喝酒,药先挪一挪?”唐守安摇头:“别在饭桌上自己改,先按你的方案办。”", + "hotspot": { + "x": 18, + "y": 0, + "w": 68, + "h": 99 + }, + "programDetailInset": { + "label": "手边近景", + "x": 8, + "y": 42, + "w": 50, + "h": 56, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C09-MS010", + "S01-C09-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P06", + "type": "event", + "eventId": "S01-H36", + "emotionMomentId": "", + "assetName": "S01-C09-P06-H36-stop-and-stay-v1.jpg", + "caption": "第三圈还没走完,赵伯出汗、手抖、回答变慢;唐守安先停酒、移开杯子,只留人陪在近处。", + "secondaryCaption": "", + "interactionPrompt": "发现出汗、手抖、反应变慢后,先停酒、移开危险物并留下人陪伴,这样更稳妥吗?", + "shortDialogue": "唐守安说:“先停酒,别让他一个人。能不能安全吞咽先看清,严重时马上联系急救。”", + "hotspot": { + "x": 22, + "y": 0, + "w": 62, + "h": 99 + }, + "programDetailInset": { + "label": "手边近景", + "x": 35, + "y": 42, + "w": 36, + "h": 39, + "anchor": "top-right" + }, + "audioCueIds": [ + "S01-C09-MS014", + "S01-C09-MS015", + "S01-C09-MS016" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM09", + "assetName": "S01-C09-P07-EM09-keep-my-seat-v1.jpg", + "caption": "酒杯停下后,赵伯仍坐在老位置。把座位留着,等他自己说愿不愿意坐下。", + "secondaryCaption": "", + "interactionPrompt": "你愿意怎样让赵伯先说出自己的担心?", + "shortDialogue": "", + "hotspot": { + "x": 24, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS004", + "S01-C09-MS005", + "S01-C09-MS007", + "S01-C09-MS008", + "S01-C09-MS009", + "S01-C09-TE900" + ], + "tableEcho": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg", + "caption": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C09-MS017", + "S01-C09-MS018", + "S01-C09-TE900", + "S01-C09-MS019" + ], + "tableEcho": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "memoryDisplayLines": [ + "陪他把选择说出来。", + "今天:开车就换茶或白水。", + "回家聊:先听赵伯把担心说完。" + ], + "cliffhanger": "赵伯推远酒盅,人和饭仍留在桌边;门外开始敲隔断。" + } + ] + }, + "S01-C10": { + "pages": [ + { + "pageId": "S01-C10-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P01-one-palm-space-v1.jpg", + "caption": "旧桌留在后厨,女炊事员端碗回来,桌上只剩一掌空。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-MT000", + "S01-C10-MS001", + "S01-C10-MS002" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P02-opening-ensemble-v1.jpg", + "caption": "端碗的、添饭的、接电话的、端饭盒的,全站在一张不能坐的桌边。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁想坐下吃一顿完整的饭,谁又被别的事情拉走。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-MS003", + "S01-C10-MS005", + "S01-C10-MS006", + "S01-C10-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P03", + "type": "event", + "eventId": "S01-H37", + "emotionMomentId": "", + "assetName": "S01-C10-P03-H37-standing-meal-v1.jpg", + "caption": "女炊事员趁出菜空隙站着扒饭,脚边没有凳子,桌上也找不到落碗处。", + "secondaryCaption": "", + "interactionPrompt": "每天都在出菜间隙快速站着吃完,这样妥当吗?", + "shortDialogue": "女炊事员笑说:“站着吃快。”明远看着空不了的桌:“快,不等于每天都该这样。”", + "hotspot": { + "x": 21, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS001", + "S01-C10-MS002", + "S01-C10-MS003", + "S01-C10-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P04", + "type": "event", + "eventId": "S01-H38", + "emotionMomentId": "", + "assetName": "S01-C10-P04-H38-open-palm-v3.jpg", + "caption": "赵伯端着大碗、握着添饭勺;明远伸出手掌,清楚说自己已经饱了。", + "secondaryCaption": "", + "outsideActionLabel": "打开赵伯这一刻", + "artTapHint": "画中赵伯也能点", + "interactionPrompt": "用碗大、吃得多证明年轻男性有本事,这样妥当吗?", + "shortDialogue": "赵伯举起大碗:“男人吃饭,碗小了哪顶事?”明远摊开手掌:“赵叔,我已经饱了。”", + "hotspot": { + "x": 17, + "y": 0, + "w": 69, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS008", + "S01-C10-MS009", + "S01-C10-MS010", + "S01-C10-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P05", + "type": "event", + "eventId": "S01-H39", + "emotionMomentId": "", + "assetName": "S01-C10-P05-H39-phone-and-cold-meal-v1.jpg", + "caption": "长卷线座机又响,明远半坐半起去接;冷饭不再冒热气,墙钟早过了饭点。", + "secondaryCaption": "", + "interactionPrompt": "连续久坐接订席,正餐一拖再拖,这样妥当吗?", + "shortDialogue": "明远说:“接完这一个就吃。”电话那头又来一桌,他的筷子再次放下。", + "hotspot": { + "x": 18, + "y": 0, + "w": 70, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS003", + "S01-C10-MS004", + "S01-C10-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P06", + "type": "event", + "eventId": "S01-H40", + "emotionMomentId": "", + "assetName": "S01-C10-P06-H40-table-not-usable-v1.jpg", + "caption": "秦师傅说桌一直给自己人留着,可菜筐、酒箱和账本压满桌面,他的饭盒也无处放。", + "secondaryCaption": "", + "interactionPrompt": "口头说“给自己人留桌”,实际长期堆物不能坐,这样妥当吗?", + "shortDialogue": "秦师傅说:“这桌一直留着呢。”女炊事员指指酒箱:“留给谁了,酒瓶?”", + "hotspot": { + "x": 20, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS006", + "S01-C10-MS007", + "S01-C10-MS014" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM10", + "assetName": "S01-C10-P07-EM10-put-meal-down-v1.jpg", + "caption": "座机又响,明远一手拿听筒,一手端冷饭盒,桌上没有落碗处。", + "secondaryCaption": "", + "interactionPrompt": "你想怎样帮他把这一顿饭重新放回生活里?", + "shortDialogue": "", + "hotspot": { + "x": 20, + "y": 0, + "w": 66, + "h": 99 + }, + "programDetailInset": { + "label": "饭盒近景", + "x": 44, + "y": 45, + "w": 33, + "h": 37, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C10-MS005", + "S01-C10-MS012", + "S01-C10-MS013", + "S01-C10-TE900" + ], + "tableEcho": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P08-plan-line-last-stool-v1.jpg", + "caption": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-TE900", + "S01-C10-MS014" + ], + "tableEcho": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "cliffhanger": "规划红线压过桌脚,最后一张凳子被推走。字幕写着:“五年后,这张图再次展开。”" + } + ] + }, + "S01-C11": { + "pages": [ + { + "pageId": "S01-C11-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P01-pen-above-paper-v1.jpg", + "caption": "旧图再次展开,秦师傅握着笔,先看旧桌,再看等他签字的人。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MT000", + "S01-C11-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P02-opening-ensemble-v1.jpg", + "caption": "小满擦展柜,秦师傅悬笔,施工负责人分通道;林秀兰与唐守安都在等他的决定。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在保存,谁在检查,谁在让路,谁又握着最后要落的笔。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MS002", + "S01-C11-MS003", + "S01-C11-MS004", + "S01-C11-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P03", + "type": "event", + "eventId": "S01-H41", + "emotionMomentId": "", + "assetName": "S01-C11-P03-H41-display-and-seat-v1.jpg", + "caption": "小满把玻璃与展板擦亮,回头才看见旧长凳正被搬走,林秀兰还在等她看那块空位。", + "secondaryCaption": "", + "interactionPrompt": "只把“健康、互助”做成展板,实际服务没有变化,这样妥当吗?", + "shortDialogue": "小满说:“这些字要留下。”林秀兰回她:“字留下了,人坐哪儿,也得留下。”", + "hotspot": { + "x": 20, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS002", + "S01-C11-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P04", + "type": "event", + "eventId": "S01-H42", + "emotionMomentId": "", + "assetName": "S01-C11-P04-H42-inspect-old-table-v1.jpg", + "caption": "秦师傅摸着旧桌沿想直接搬走,卷尺和检查表已递到手边,松动桌腿还没有人作结论。", + "secondaryCaption": "", + "interactionPrompt": "不检查结构安全,因怀旧就直接继续给客人使用,这样妥当吗?", + "shortDialogue": "秦师傅摸着桌沿:“它撑了几十年。”施工负责人说:“先查清还能不能安全撑今天。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS001", + "S01-C11-MS006", + "S01-C11-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P05", + "type": "event", + "eventId": "S01-H43", + "emotionMomentId": "", + "assetName": "S01-C11-P05-H43-clear-passage-v1.jpg", + "caption": "施工负责人亲手合上材料区围挡,又把通道口让开;唐守安和员工抬着长凳顺利通过。", + "secondaryCaption": "", + "interactionPrompt": "施工材料与人员必经路线分开,并保留清楚通道,这样更稳妥吗?", + "shortDialogue": "施工负责人说:“人走这一边,材料走那一边。看得见的通道,才真能走。”", + "hotspot": { + "x": 18, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS004", + "S01-C11-MS005", + "S01-C11-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P06", + "type": "event", + "eventId": "S01-H44", + "emotionMomentId": "", + "assetName": "S01-C11-P06-H44-pen-before-signature-v1.jpg", + "caption": "秦师傅把旧物清单又看一遍,笔尖终于落下半寸;林秀兰站在侧后,没有替他拿笔。", + "secondaryCaption": "", + "interactionPrompt": "白天签下旧物处置后,夜里仍保存完整桌板、铜牌与钥匙记录,这样更稳妥吗?", + "shortDialogue": "林秀兰问:“你不是说这桌不动吗?”秦师傅说:“饭店得活下来,人才能回来。”", + "hotspot": { + "x": 20, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS006", + "S01-C11-MS007", + "S01-C11-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM11", + "assetName": "S01-C11-P07-EM11-wrap-and-record-v1.jpg", + "caption": "夜里,秦师傅把桌板、铜牌和两枚破票逐件包好;空白记录还等他留下些什么。", + "secondaryCaption": "", + "interactionPrompt": "除了保存旧物,你想请秦师傅再留下些什么?", + "shortDialogue": "", + "hotspot": { + "x": 21, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS009", + "S01-C11-MS010", + "S01-C11-MS011", + "S01-C11-TE900" + ], + "tableEcho": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P08-can-we-open-now-v1.jpg", + "caption": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MS012", + "S01-C11-MS013", + "S01-C11-MS014" + ], + "tableEcho": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "cliffhanger": "十八年后,小满认出抽屉里的旧饭票夹。她没有擅自拿,只第三次问:“现在能开吗?”" + } + ] + }, + "S01-C12": { + "pages": [ + { + "pageId": "S01-C12-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P01-key-pauses-half-turn-v1.jpg", + "caption": "爷爷点了头,小满才取下饭票夹;钥匙转到一半,她又停下来说明。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MT000", + "S01-C12-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P02-modern-table-ensemble-v1.jpg", + "caption": "旧桌板被人擦亮,纸单被人铺开;赵伯按加号,明远拿起信息卡,秦师傅仍站在后厨门里。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在核对旧物,谁把点餐方式摆出来,谁又想替一家人一次安排完。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MS002", + "S01-C12-MS003", + "S01-C12-MS005", + "S01-C12-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P03", + "type": "event", + "eventId": "S01-H45", + "emotionMomentId": "", + "assetName": "S01-C12-P03-H45-provenance-chain-v1.jpg", + "caption": "小满先拍桌板全貌,再拍红漆、孔位和票据;林秀兰擦板,秦师傅把纸筒递到手边。", + "secondaryCaption": "", + "interactionPrompt": "征得秦师傅同意后开柜,并用红漆、孔位、板字、照片和票据共同核对,这样更稳妥吗?", + "shortDialogue": "小满说:“这回能开吗?”秦师傅点头。她又说:“一件一件记,别让一个孔替所有证据说话。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS001", + "S01-C12-MS002", + "S01-C12-MS003", + "S01-C12-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P04", + "type": "event", + "eventId": "S01-H46", + "emotionMomentId": "", + "assetName": "S01-C12-P04-H46-real-choice-counter-v1.jpg", + "caption": "小满把大字纸单推向老人,员工当面等他开口;扫码牌在旁,现金盒也正常打开。", + "secondaryCaption": "", + "interactionPrompt": "在扫码之外保留大字纸单、人工点餐,并保持正常现金收款,这样更稳妥吗?", + "shortDialogue": "小满说:“会扫码就扫,想看纸单就看纸单;有人在这儿帮,现金也照常收。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS005", + "S01-C12-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P05", + "type": "event", + "eventId": "S01-H47", + "emotionMomentId": "", + "assetName": "S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg", + "caption": "十个人围着真实饭桌,赵伯熟练按下加号;乐乐只按住自己的手,先问每份多大。", + "secondaryCaption": "", + "interactionPrompt": "因“七十周年不能空”继续加菜,这样妥当吗?", + "shortDialogue": "赵伯说:“十四不好听,再来俩。”乐乐按住减号:“十个人十四道,已经不是数学问题了。”", + "hotspot": { + "x": 15, + "y": 0, + "w": 69, + "h": 99 + }, + "programEvidenceInset": { + "kind": "order-count", + "kicker": "画中近景", + "screenLabel": "手机点单", + "countLabel": "已点", + "countValue": 14, + "countUnit": "道", + "plusGlyph": "+", + "note": "赵伯还在加菜", + "anchor": "top-right", + "ariaLabel": "画中近景:手机显示已点十四道,赵伯还要按加号继续加菜。" + }, + "audioCueIds": [ + "S01-C12-MS007", + "S01-C12-MS008", + "S01-C12-MS009" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P06", + "type": "event", + "eventId": "S01-H48", + "emotionMomentId": "", + "assetName": "S01-C12-P06-H48-card-is-not-a-promise-v1.jpg", + "caption": "明远把信息卡往父亲面前一放,像找到了省心答案;乐乐却指着空白栏,等他把话听完。", + "secondaryCaption": "", + "interactionPrompt": "看到“烹调方式:炖;本份约供2—3人;可选小份”,就理解成“健康保证”,这样妥当吗?", + "shortDialogue": "明远说:“写得这么健康,多点一份没事。”乐乐问:“它说怎么做、多大份,又没说能治病吧?”", + "hotspot": { + "x": 18, + "y": 0, + "w": 67, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS010", + "S01-C12-MS011", + "S01-C12-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM12", + "assetName": "S01-C12-P07-EM12-hand-choice-back-v1.jpg", + "caption": "手机、语音、纸单和服务员都在;乐乐把纸单放到赵伯手边,明远终于停下来等他开口。", + "secondaryCaption": "", + "interactionPrompt": "信息都在了,最后一步怎样交还给赵伯?", + "shortDialogue": "", + "hotspot": { + "x": 18, + "y": 0, + "w": 67, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS005", + "S01-C12-MS006", + "S01-C12-TE900" + ], + "tableEcho": "让人看懂、让人开口,才算把选择递到手里。", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P08-rolled-speech-cliffhanger-v1.jpg", + "caption": "让人看懂、让人开口,才算把选择递到手里。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MS013", + "S01-C12-MS014-ATTR", + "S01-C12-MS014", + "S01-C12-MS015", + "S01-C12-MS016" + ], + "tableEcho": "让人看懂、让人开口,才算把选择递到手里。", + "cliffhanger": "秦师傅把稿子重新卷起,只说:“先上菜。”林秀兰看见末行写着“请晚班的人原谅我”。" + } + ] + }, + "S01-C13": { + "pages": [ + { + "pageId": "S01-C13-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P01-three-chopsticks-collide-v1.jpg", + "caption": "乐乐刚说饱,三双公筷却同时伸来;只听“叮”的一声,桌边静了半拍。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MT000", + "S01-C13-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P02-opening-ensemble-v1.jpg", + "caption": "乐乐护碗,明远推盘,赵伯的姓名牌去了侧桌;小满手里的软尺,还没有展开。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁还在替人夹,谁把自己的菜推过去,谁又让姓名牌先离了席。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MS002", + "S01-C13-MS003", + "S01-C13-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P03", + "type": "event", + "eventId": "S01-H49", + "emotionMomentId": "", + "assetName": "S01-C13-P03-H49-ask-before-serving-v1.jpg", + "caption": "三位长辈都想照顾孩子,却没有一个先问;公筷干净,也不能替乐乐说“要”。", + "secondaryCaption": "", + "interactionPrompt": "不问乐乐就轮流替他夹菜,这样妥当吗?", + "shortDialogue": "赵伯笑:“还排上号了。”乐乐护住碗:“公筷也是筷子,先问我呀!”", + "hotspot": { + "x": 14, + "y": 3, + "w": 70, + "h": 94 + }, + "audioCueIds": [ + "S01-C13-MS001", + "S01-C13-MS002", + "S01-C13-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P04", + "type": "event", + "eventId": "S01-H50", + "emotionMomentId": "", + "assetName": "S01-C13-P04-H50-name-card-leaves-first-v1.jpg", + "caption": "小满想把照顾安排周全,却先把姓名牌移去侧桌;赵伯本人仍坐在大家中间。", + "secondaryCaption": "", + "interactionPrompt": "因担心赵伯,未经商量就把他安排到隔开的桌上,这样妥当吗?", + "shortDialogue": "赵伯探身说:“我人还在这儿,牌先被请走了?”小满的手停在半空。", + "hotspot": { + "x": 43, + "y": 1, + "w": 54, + "h": 98 + }, + "programDetailInset": { + "label": "桌边近景", + "x": 65, + "y": 36, + "w": 35, + "h": 39, + "anchor": "top-right", + "labelAnchor": "bottom-left" + }, + "audioCueIds": [ + "S01-C13-MS006", + "S01-C13-MS007", + "S01-C13-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P05", + "type": "event", + "eventId": "S01-H51", + "emotionMomentId": "", + "assetName": "S01-C13-P05-H51-family-double-standard-v1.jpg", + "caption": "明远把自己的甜饮举到嘴边,另一只手还搭在推给乐乐的菜盘上;乐乐抬眼看他。", + "secondaryCaption": "", + "interactionPrompt": "大人提醒孩子少喝,自己却仍举着甜饮,这样妥当吗?", + "shortDialogue": "明远说:“饮料少喝。”乐乐看着他的瓶子:“爸爸,那咱们一起少喝,好吗?”", + "hotspot": { + "x": 18, + "y": 0, + "w": 68, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS004", + "S01-C13-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P06", + "type": "event", + "eventId": "S01-H52", + "emotionMomentId": "", + "assetName": "S01-C13-P06-H52-tape-stays-rolled-v1.jpg", + "caption": "小满看着姓名牌和卷起的软尺,没有先量侧桌;这一回,她决定先问坐桌的人。", + "secondaryCaption": "", + "interactionPrompt": "不先挪人,先问赵伯想坐哪里,再调整共同桌,这样更稳妥吗?", + "shortDialogue": "小满问:“赵伯,您想坐哪儿?”赵伯把椅子往里收了收:“我还坐大家旁边。”", + "hotspot": { + "x": 24, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS009", + "S01-C13-MS010", + "S01-C13-MS011", + "S01-C13-MS012", + "S01-C13-MS013", + "S01-C13-MS014", + "S01-C13-MS015" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM13", + "assetName": "S01-C13-P07-EM13-listen-to-child-v1.jpg", + "caption": "三双公筷终于都停下。乐乐还护着碗,等大人第一次不替他说话。", + "secondaryCaption": "", + "interactionPrompt": "这一回,家里人怎样让乐乐自己选?", + "shortDialogue": "", + "hotspot": { + "x": 24, + "y": 0, + "w": 54, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS010", + "S01-C13-MS011", + "S01-C13-MS012", + "S01-C13-TE900" + ], + "tableEcho": "这一次,大人终于听孩子把话说完。", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P08", + "type": "memory-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P08-cook-crosses-threshold-v1.jpg", + "caption": "为你好之前,先听一句“我要不要”;照顾也要把选择还给人。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MS016", + "S01-C13-MS017", + "S01-C13-MS018" + ], + "tableEcho": "", + "cliffhanger": "秦师傅端起一锅白菜热汤面,终于跨过躲了整晚的后厨门槛。" + } + ] + }, + "S01-C14": { + "pages": [ + { + "pageId": "S01-C14-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P01-lid-opens-old-scent-v1.jpg", + "caption": "锅盖一揭,白菜热汤面冒起白汽;赵伯一句“就这”,忽然停在了半截。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MT000", + "S01-C14-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P02-responsibility-ensemble-v1.jpg", + "caption": "秦师傅扶住旧板,林秀兰排开证据;唐守安停在门框,赵伯从普通座端茶起身。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在说自己的责任,谁只补证据,谁没有往主位走,谁仍在桌上举杯。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MS002", + "S01-C14-MS003", + "S01-C14-MS004", + "S01-C14-MS005", + "S01-C14-MS006", + "S01-C14-MS007-ATTR", + "S01-C14-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P03", + "type": "event", + "eventId": "S01-H53", + "emotionMomentId": "", + "assetName": "S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg", + "caption": "秦师傅只讲白菜、面、热汤、做法与份量;旧味能留人情,不能替代治疗。", + "secondaryCaption": "", + "interactionPrompt": "因为是老菜,就宣传成“祖传降糖面”,这样妥当吗?", + "shortDialogue": "秦师傅说:“就是白菜、面和一锅热汤。讲做法、讲份量,别给它封神。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 68, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS001", + "S01-C14-MS002", + "S01-C14-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P04", + "type": "event", + "eventId": "S01-H54", + "emotionMomentId": "", + "assetName": "S01-C14-P04-H54-evidence-chain-v1.jpg", + "caption": "林秀兰把两枚破票、铜牌、红漆、孔位、板字与照片逐项核对;没有一件物证独自定案。", + "secondaryCaption": "", + "interactionPrompt": "把票据、铜牌、暗红漆、孔位、板字和照片一起核对,这样更稳妥吗?", + "shortDialogue": "林秀兰说:“每件东西只说自己知道的那一段,合起来,故事才站得稳。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 71, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS004", + "S01-C14-MS007-ATTR", + "S01-C14-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P05", + "type": "event", + "eventId": "S01-H55", + "emotionMomentId": "", + "assetName": "S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg", + "caption": "唐守安停在门框,没有去主位;赵伯拉开身边的普通座,秦师傅的话仍由秦师傅说完。", + "secondaryCaption": "", + "interactionPrompt": "大家请他坐主位,他选择普通座并让秦师傅自己说完,这样更稳妥吗?", + "shortDialogue": "赵伯喊:“给唐大夫留个座。”唐守安摆手:“普通座就好,先听老秦把话说完。”", + "hotspot": { + "x": 48, + "y": 1, + "w": 48, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS008", + "S01-C14-MS009", + "S01-C14-MS010" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P06", + "type": "event", + "eventId": "S01-H56", + "emotionMomentId": "", + "assetName": "S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg", + "caption": "赵伯把蓝花盖碗转半圈,主动同大家碰杯;不用酒证明能耐,他也没有离开这张桌。", + "secondaryCaption": "", + "interactionPrompt": "用茶参加碰杯,也不再要求别人喝酒,这样更稳妥吗?", + "shortDialogue": "赵伯说:“这回我当个不劝酒、不硬撑的骨干。老伙计,茶也算一杯。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 65, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS011", + "S01-C14-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM14", + "assetName": "S01-C14-P07-EM14-watch-face-down-v1.jpg", + "caption": "父子同高坐下,那只曾让问话匆匆结束的手表,还亮在两人之间。", + "secondaryCaption": "", + "interactionPrompt": "唐守安怎样让儿子把那句旧话真正说完?", + "shortDialogue": "", + "hotspot": { + "x": 8, + "y": 1, + "w": 84, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS012", + "S01-C14-MS013", + "S01-C14-MS014", + "S01-C14-MS015", + "S01-C14-TE900" + ], + "tableEcho": "会解决问题的人,也可以学着先听一会儿。", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P08", + "type": "memory-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P08-measure-the-empty-place-v1.jpg", + "caption": "会解决问题,也要先听一会儿;桌子能否回来,先看那块空位最终坐回谁。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MS016", + "S01-C14-MS017" + ], + "tableEcho": "", + "cliffhanger": "秦师傅问:“十五桌还能回来吗?”小满没有回答,先拿软尺重新量空位。" + } + ] + }, + "S01-C15": { + "pages": [ + { + "pageId": "S01-C15-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P01-chair-opens-for-late-workers-v1.jpg", + "caption": "三周后,晚市将收;小满先拉开椅子,门口那几个人终于不用再等。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C15-MT000", + "S01-C15-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P02-everyone-makes-room-ensemble-v1.jpg", + "caption": "小满把椅子朝共同桌拉开,赵伯抬手招呼;两位晚班工友走进门,来晚的人也有位置。", + "secondaryCaption": "", + "interactionPrompt": "看看小满把椅子拉向哪里,再看门口的人正往哪里走。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C15-MS002", + "S01-C15-MS003", + "S01-C15-MS004", + "S01-C15-MS005", + "S01-C15-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P03", + "type": "event", + "eventId": "S01-H57", + "emotionMomentId": "", + "assetName": "S01-C15-P03-H57-three-real-portions-v1.jpg", + "caption": "小甄先看来了几个人,再把普通份、小份、半份三种真实餐盘推到大家眼前。", + "secondaryCaption": "", + "interactionPrompt": "菜单提供多种份量并写建议用餐人数,这样更稳妥吗?", + "shortDialogue": "小甄说:“先看几个人、每份多大。少点不够再添,比猜一桌需要多少更踏实。”", + "hotspot": { + "x": 17, + "y": 1, + "w": 55, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS003", + "S01-C15-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P04", + "type": "event", + "eventId": "S01-H58", + "emotionMomentId": "", + "assetName": "S01-C15-P04-H58-water-within-reach-v1.jpg", + "caption": "小甄把白水壶放到共同桌边;其他饮品仍在侧柜,先问本人,再决定要不要。", + "secondaryCaption": "", + "interactionPrompt": "默认让白水容易取得,酒和甜饮由本人选择,不替所有人自动摆上,这样更稳妥吗?", + "shortDialogue": "小甄说:“白水先放手边,其他饮品看清信息、问过本人,再决定要不要。”", + "hotspot": { + "x": 41, + "y": 1, + "w": 49, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS003", + "S01-C15-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P05", + "type": "event", + "eventId": "S01-H59", + "emotionMomentId": "", + "assetName": "S01-C15-P05-H59-ask-before-serving-v1.jpg", + "caption": "明远的公筷停在乐乐碗外。他先看孩子的脸,再问:“这个还要吗?”", + "secondaryCaption": "", + "interactionPrompt": "夹菜前先问乐乐还要不要,这样更稳妥吗?", + "shortDialogue": "明远问:“这个还要吗?”乐乐点头:“要一点,我自己夹。”明远把公筷递过去。", + "hotspot": { + "x": 12, + "y": 1, + "w": 58, + "h": 98 + }, + "programDetailInset": { + "label": "公筷停住", + "x": 22, + "y": 38, + "w": 51, + "h": 57, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C15-MS007", + "S01-C15-MS008", + "S01-C15-MS009", + "S01-C15-MS010", + "S01-C15-MS011", + "S01-C15-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P06", + "type": "event", + "eventId": "S01-H60", + "emotionMomentId": "", + "assetName": "S01-C15-P06-H60-check-before-packing-v1.jpg", + "caption": "员工没有把剩菜一股脑装盒;她逐盘询问,也把保存和再加热提示一项项说明。", + "secondaryCaption": "", + "interactionPrompt": "所有剩菜不分情况都必须打包,这样妥当吗?", + "shortDialogue": "员工问:“这些要带走吗?我先把能不能保存、怎么再加热给您说清。”", + "hotspot": { + "x": 31, + "y": 1, + "w": 58, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS016" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM15", + "assetName": "S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg", + "caption": "同一机位里,人已坐满;老吕带来的右缺口蓝边旧碗,还是空的。", + "secondaryCaption": "", + "interactionPrompt": "这只旧碗怎样留在今天的第十五桌边?", + "shortDialogue": "", + "hotspot": { + "x": 12, + "y": 1, + "w": 76, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS013", + "S01-C15-MS014", + "S01-C15-MS015", + "S01-C15-MS016", + "S01-C15-TE900", + "S01-C15-MS017" + ], + "tableEcho": "桌子回来了,人也都坐回来了。", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P08", + "type": "memory-season-close", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P08-people-seated-season-close-v2.jpg", + "caption": "第一回少的桌,如今有人坐回来了。铜牌回到桌沿,晚班工友也坐回来了。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "programDetailInset": { + "label": "秦师傅", + "x": 72, + "y": 7, + "w": 25, + "h": 44, + "anchor": "top-right" + }, + "audioCueIds": [ + "S01-C15-MS016", + "S01-C15-TE900", + "S01-C15-MS017", + "S01-C15-MS018" + ], + "tableEcho": "", + "cliffhanger": "快门按下,旧铜牌“15”重新固定。原来缺的不是一张桌,是这些人应该有的位置。" + } + ] + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/data/releaseInfo.js b/TUICallKit-Vue3/native/tang-detective/data/releaseInfo.js new file mode 100644 index 0000000..600fe6b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/data/releaseInfo.js @@ -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', +}) diff --git a/TUICallKit-Vue3/native/tang-detective/data/season.js b/TUICallKit-Vue3/native/tang-detective/data/season.js new file mode 100644 index 0000000..d88339b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/data/season.js @@ -0,0 +1,5019 @@ +// Generated from the reviewed text snapshot. Do not hand-edit. +module.exports = { + "schemaVersion": "1.0.0-miniprogram", + "generatedAt": "2026-08-12T11:55:16.266Z", + "seasonMeta": { + "title": "唐侦探:桂香里的第十五桌", + "publisher": "甄养堂", + "subtitle": "一张桌,三代人,四十八年的吃饭习惯", + "format": "微信小程序横屏健康互动连环画", + "reviewLabel": "健康科普内容待甄养堂正式审签", + "purpose": "在真实中国饭桌场景里练习观察、判断和更稳妥的生活动作,不替代诊断或个体化治疗。", + "pingshuAudio": "/audio/table-15-pingshu-v3-directed-mobile.mp3", + "fullStoryAudio": "/audio/tang-detective-v4-full.mp3", + "contentVersion": "横屏互动连环画 · 第一季 V4.1 情感精修版" + }, + "interactionModel": { + "orientation": "手机横屏", + "entry": "封面进入章节目录,再进入横屏人物主场景;章节故事文字和玩法提示在同一章可读", + "primaryTarget": "人物是主要点击入口,物件是人物行为的手边证据", + "sceneLayout": "主场景约占60%,章节信息与进度侧栏约占40%;点击人物后判断浮窗仍保持人物情节约60%、选择区约40%", + "modal": "左侧人物、年代、身份、动作、台词与证据;右侧红叉绿勾判断;老人手机横屏优先大字和大按钮", + "repeatedActorRule": "每章固定4个事件,但个别章节只有3个可点击人物;同一人物可能承载2个连续事件,第一次完成后要明确提示再次点击", + "feedback": "答错隐藏原选项并给温和新线索,点击“再判断一次”后重选;答对给一句原因和一个可执行动作", + "chapterClosure": "完成4个正式事件后解锁1个无标准答案的情感互动;玩家选择后获得一条桌边回声", + "audioPolicy": "先做到纯文字完整可玩;音频后置,不阻塞文字、人物站位和互动验收" + }, + "counts": { + "chapters": 15, + "events": 60, + "emotions": 15, + "scenePeople": 80 + }, + "chapters": [ + { + "chapterId": "S01-C01", + "chapterNumber": 1, + "title": "开席前,少了一张桌", + "year": "2026", + "location": "桂香大饭店宴会前厅", + "sceneDescription": "2026年宴会前厅。十四桌与十六桌之间留出全画最大的空位;前景是纸请柬、手机和压痕,中景是小满、赵伯与乐乐,秦师傅只在后厨门影里。", + "sceneAlt": "2026年的桂香大饭店,中央留着一块少桌后的空地", + "narration": "老厂七十周年重聚宴就要开席。几张纸请柬明明写着“十五桌”,电子座位图却从十四直接跳到十六;地毯上,还留着四个刚压出来的桌脚印。", + "dialogue": "乐乐蹲下一比划:“它不是没来过,是刚走。”", + "act": "第一幕 · 一张桌为什么不见了", + "handnote": [ + "找座位要保留扫码、纸单和人工等多种方式。", + "发现矛盾先留证、核对和询问,不急着怪人。" + ], + "cliffhanger": "稿纸背面露出“请他们原谅……”;秦师傅听见“十五号铜牌”,门里“哐”地落下一只锅盖。", + "people": [ + { + "instanceId": "c01-xiaoman", + "characterId": "qin-xiaoman", + "name": "秦小满", + "title": "饭店经营者", + "eraLabel": "2026 · 33岁", + "stance": "站在签到台右侧,身体朝来宾开放", + "gaze": "看向刚进门的老工友", + "action": "把二维码座位图转向来宾,另一手压着未展开的纸名单", + "prop": "平板座位图、纸质名单", + "layer": 3, + "position": { + "xPercent": 12, + "yPercent": 20, + "widthPercent": 15, + "heightPercent": 56 + }, + "hotspotIds": [ + "S01-H01" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-xiaoman.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H01" + ] + }, + { + "instanceId": "c01-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2026 · 73岁", + "stance": "站在空位左前,微微眯眼", + "gaze": "在手机菜单与空桌位之间来回", + "action": "一手捏纸请柬,一手准备在手机上继续加菜", + "prop": "纸请柬、点餐手机", + "layer": 4, + "position": { + "xPercent": 29, + "yPercent": 30, + "widthPercent": 16, + "heightPercent": 55 + }, + "hotspotIds": [ + "S01-H02" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H02" + ] + }, + { + "instanceId": "c01-lele", + "characterId": "lele", + "name": "乐乐", + "title": "会直问的小孙子", + "eraLabel": "2026 · 10岁", + "stance": "蹲在地毯压痕旁", + "gaze": "沿椅脚拖痕看向侧门", + "action": "用手掌比量四个压痕,举起手机准备拍照", + "prop": "手机、卷尺小卡", + "layer": 5, + "position": { + "xPercent": 54, + "yPercent": 48, + "widthPercent": 15, + "heightPercent": 42 + }, + "hotspotIds": [ + "S01-H03" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lele.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H03" + ] + }, + { + "instanceId": "c01-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "2026 · 72岁", + "stance": "半身藏在后厨门框内", + "gaze": "越过门缝看空出来的十五桌位置", + "action": "把卷稿和铜牌压在围裙前,脚尖却向后退", + "prop": "发言稿、十五号铜牌", + "layer": 2, + "position": { + "xPercent": 79, + "yPercent": 17, + "widthPercent": 13, + "heightPercent": 52 + }, + "hotspotIds": [ + "S01-H04" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H04" + ] + } + ], + "events": [ + { + "hotspotId": "S01-H01", + "eventNumber": 1, + "actorId": "qin-xiaoman", + "actorName": "秦小满", + "actorInstanceId": "c01-xiaoman", + "label": "只有二维码的座位图", + "actionDescription": "小满站在电子座位图旁,只把二维码转向陆续进门的老人。", + "speech": "秦小满说:“大家先扫这里找座。”赵伯说:“纸请柬我看得懂,你把纸名单也铺开,我自己慢慢找。”", + "evidence": "屏幕字号很小;旁边虽有空桌,却没有铺开纸质名单,也没人主动问是否需要帮助。", + "question": "只让老人扫码找座位,不提供纸质名单或人工帮助,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "只有扫码入口,会让不熟悉手机或看不清小字的人失去自主找座的机会。", + "retryHint": "留意几位老人举着手机却没有操作,以及旁边尚未展开的纸质名单。", + "actionAdvice": "把大字纸质名单铺在同一入口,并安排工作人员主动询问是否需要帮助。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-xiaoman.jpg" + }, + { + "hotspotId": "S01-H02", + "eventNumber": 2, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c01-zhao", + "label": "已点十四道菜的手机", + "actionDescription": "赵伯看见手机里已有十四道菜,拇指仍停在“再来一道”的加号上。", + "speech": "赵伯说:“桌上不能空,空了显得心里没人。”乐乐数着手机回他:“已经十四道了,先看看人和份量吧。”", + "evidence": "来宾只有十人,他不是看见缺少某一品类,而是想用菜数把桌面“撑起来”。", + "question": "十个人已有十四道菜,还要再点一道“撑场面”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "情分不需要用超过人数和需要的菜量来证明,点得过多也会挤掉每个人真正想选的空间。", + "retryHint": "留意来宾人数、现有十四道菜的份量,以及赵伯只是想让桌面显得更满。", + "actionAdvice": "先核对人数、现有份量和菜品结构,确有不足再补。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H03", + "eventNumber": 3, + "actorId": "lele", + "actorName": "乐乐", + "actorInstanceId": "c01-lele", + "label": "地毯上的四个新压痕", + "actionDescription": "乐乐蹲在空位旁量四个新压痕,又抬头对照纸请柬和跳号座位图。", + "speech": "乐乐说:“它不是没摆过,是刚走。先拍脚印,再把请柬和座位图放一块儿看。”", + "evidence": "地毯压痕颜色更新,椅脚拖痕一直通向侧门,说明桌子早晨很可能真正摆过。", + "question": "先点压痕,再在浮窗中把请柬和跳号座位图放在一起核对,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "把现场痕迹与请柬、座位图共同核对,比只凭一个系统页面下结论更可靠。", + "retryHint": "对照电子图的跳号与地毯上四个颜色更新的桌脚压痕。", + "actionAdvice": "拍下压痕和拖痕,收好请柬,再调取调台记录核对。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lele.jpg" + }, + { + "hotspotId": "S01-H04", + "eventNumber": 4, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c01-qin", + "label": "秦师傅没能念出口的发言稿", + "actionDescription": "秦师傅躲在后厨门影里,把没念完的稿纸重新卷紧,十五号铜牌正夹在纸筒中。", + "speech": "秦师傅隔门说:“十五桌的人……齐了就告诉我。”乐乐说:“铜牌先放回稿纸旁,等秦爷爷出来把话说清。”", + "evidence": "稿首写着“给十五桌的老伙计们”,纸背露出“请他们原谅”,更像临场退缩而不是偷桌。", + "question": "发现铜牌卷在发言稿里,先询问秦师傅而不是认定有人偷桌,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "铜牌与发言稿更像秦师傅准备坦白后临时退缩的线索,不能直接变成对他人或服务员的指控。", + "retryHint": "留意铜牌与未念完的道歉稿原本卷在一起,尚无证据证明有人偷桌。", + "actionAdvice": "保持铜牌、纸筒和发言稿的原有关系,记录来源后再询问秦师傅。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + } + ], + "sceneAsset": "/package-game/assets/scenes/gui-xiang-2026.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM01", + "chapterId": "S01-C01", + "title": "把赵伯叫进来", + "triggerPosition": "四项证据齐全、众人前往旧物展柜之前", + "character": { + "id": "zhao-jianguo", + "name": "赵建国" + }, + "sceneText": "赵伯捏平手写请柬,站在十四桌与十六桌之间。四个新桌脚压痕就在脚边,他嘴上说“别管我”,脚却一直没有挪开。", + "prompt": "这一刻,你想怎样请赵伯一起查下去?", + "playerChoices": [ + { + "choiceId": "S01-EM01-A", + "text": "叫一声“赵伯”,把纸请柬递给他,请他带大家一起核对。", + "characterFeedback": "赵伯把请柬展开:“这还差不多。别替我安排,我跟你们一块儿查。”" + }, + { + "choiceId": "S01-EM01-B", + "text": "先搬来一把普通椅子,再问他愿不愿意坐着一起等结果。", + "characterFeedback": "赵伯把椅子拉近半步:“我站得住。不过你先问这一句,我心里就舒坦。”" + } + ], + "tableEcho": "空着的位置,不是少摆一张桌,是在等一个人被叫到名字。", + "echoCategory": "温暖" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C02", + "chapterNumber": 2, + "title": "铜牌背后的两张旧票", + "year": "2026 → 1978", + "location": "桂香旧物展柜", + "sceneDescription": "现代旧物展柜斜切画面。赵伯在左前认错碗,林秀兰居中纠正展签,乐乐在低处找铜牌细节,后厨门缝保留秦师傅的白衣背影。", + "sceneAlt": "桂香大饭店里的旧物展柜与被收起的十五号铜牌", + "narration": "铜牌背面有暗红漆、旧钉孔和两张破损饭菜票。赵伯一眼认出缺口碗,林秀兰却把照片转过来:“先别抢着认,缺口在另一边。”", + "dialogue": null, + "act": "第一幕 · 一张桌为什么不见了", + "handnote": [ + "记忆要和照片、痕迹、票证互相核对。", + "厂内饭菜票不是全国粮票,名字不能张冠李戴。" + ], + "cliffhanger": "林秀兰把挂钟拨到十一点半。宴会试音忽然变成四十八年前的厂广播。", + "people": [ + { + "instanceId": "c02-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2026 · 73岁", + "stance": "俯身贴近展柜玻璃", + "gaze": "盯住右缺口蓝边搪瓷碗", + "action": "指着碗认领,另一手仍捏着自己的老照片", + "prop": "旧照片、搪瓷碗", + "layer": 4, + "position": { + "xPercent": 7, + "yPercent": 25, + "widthPercent": 16, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H06" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H06" + ] + }, + { + "instanceId": "c02-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "2026 · 69岁", + "stance": "站在展柜中央侧前", + "gaze": "先看展签,再看赵伯手中的照片", + "action": "用票夹压住错误展签,把铜牌翻面拍照量尺寸", + "prop": "饭票夹、手机、软尺", + "layer": 5, + "position": { + "xPercent": 35, + "yPercent": 18, + "widthPercent": 16, + "heightPercent": 61 + }, + "hotspotIds": [ + "S01-H05", + "S01-H08" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H05", + "S01-H08" + ] + }, + { + "instanceId": "c02-lele", + "characterId": "lele", + "name": "乐乐", + "title": "会直问的小孙子", + "eraLabel": "2026 · 10岁", + "stance": "蹲在展柜右下角", + "gaze": "从铜牌铁丝抬到合影最边缘", + "action": "指认破损票、棕布包与桌沿旧漆,写下“待核对”", + "prop": "线索卡、铅笔", + "layer": 6, + "position": { + "xPercent": 61, + "yPercent": 47, + "widthPercent": 14, + "heightPercent": 41 + }, + "hotspotIds": [ + "S01-H07" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lele.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H07" + ] + }, + { + "instanceId": "c02-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "2026 · 72岁", + "stance": "只露背影在后厨门缝", + "gaze": "侧耳听展柜前的谈话", + "action": "听到铜牌后失手碰落锅盖", + "prop": "白围裙、锅盖", + "layer": 1, + "position": { + "xPercent": 82, + "yPercent": 15, + "widthPercent": 11, + "heightPercent": 48 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "indigo", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H05", + "eventNumber": 5, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c02-lin", + "label": "写成“粮票”的展签", + "actionDescription": "林秀兰用旧票夹压住写着“全国粮票”的展签,示意先看票面单位。", + "speech": "林秀兰说:“先看票面。这是桂香厂饭菜票,不是全国粮票。名字写错了,后头那个人的日子也会跟着写错。”", + "evidence": "票面印的是桂香机械厂内部结算信息,使用范围与全国粮票不同。", + "question": "把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "厂内饭菜票与全国粮票的使用范围和用途不同,名称准确才能让年代和人的经历不被混淆。", + "retryHint": "查看票面单位、使用地点和结算用途,别只看它们都叫“票”。", + "actionAdvice": "查看票面单位和适用范围,改正展签并保留原始来源说明。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + }, + { + "hotspotId": "S01-H06", + "eventNumber": 6, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c02-zhao", + "label": "缺口搪瓷碗", + "actionDescription": "赵伯隔着玻璃认领缺口碗,手已经指向自己,林秀兰却把旧照片翻了个面。", + "speech": "赵伯说:“这缺口我认得。”林秀兰说:“照片转过来再看,你那只缺口在左,这只在右。慢慢看,没人拿一次记错笑你。”", + "evidence": "展柜碗缺口在右,赵伯旧照里的白底绿边碗缺口在左,两只碗不能只凭一句回忆合并。", + "question": "只凭赵伯一句“这是我的”就确认物主,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "尊重老人记忆不等于停止核对;多项证据互证,既能认准物主,也避免因一次记错否定本人。", + "retryHint": "对照缺口左右、搪瓷碗颜色与旧照片中的持碗人。", + "actionAdvice": "转正照片核对左右、颜色和旧账,再把物主与借展状态写清。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H07", + "eventNumber": 7, + "actorId": "lele", + "actorName": "乐乐", + "actorInstanceId": "c02-lele", + "label": "合影边缘的布包与桌沿铁包边", + "actionDescription": "乐乐沿旧合影边缘找到半张年轻面孔、棕布包和桌沿铁包边。", + "speech": "乐乐说:“布包像唐爷爷的,桌沿漆也像铜牌背后。我先记‘待核对’,不抢着写‘就是’。”", + "evidence": "布包能帮助辨认年轻小唐,暗红旧漆能帮助追踪桌子,但两者都还需要别的线索互证。", + "question": "用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "跨年代重复物件有助于认人和追踪桌子,但相似不等于已经证明。", + "retryHint": "相同布包和漆色只能提供核对方向,还需要其他照片与实物印证。", + "actionAdvice": "把布包、人物位置和暗红铁包边加入线索卡,等待其他照片与实物互证。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lele.jpg" + }, + { + "hotspotId": "S01-H08", + "eventNumber": 8, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c02-lin", + "label": "铜牌背面红漆和钉孔", + "actionDescription": "林秀兰把铜牌翻面拍下红漆和钉孔,再放大铁丝上两枚边孔磨裂的饭菜票。", + "speech": "林秀兰说:“红漆拍下来,孔距也量上。证据会说话,可别逼它一次把所有话说完。”", + "evidence": "红漆、孔位与破损票可留作后续比对,但单独一张近照不能证明四十八年的全部经历。", + "question": "拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "漆痕、孔位和破票可供后续比对,但单件物证不足以还原完整历史。", + "retryHint": "一张铜牌的红漆与钉孔只能证明部分痕迹,不能独自讲完四十八年。", + "actionAdvice": "保留铜牌原状,拍摄尺寸与近照,记录两枚破票的连接关系,等待旧桌板出现。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境", + "饮食行为", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + } + ], + "sceneAsset": "/package-chapter-02/assets/scenes/gui-xiang-1978.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM02", + "chapterId": "S01-C02", + "title": "慢一点认", + "triggerPosition": "旧物核对结束、挂钟被拨回十一点半之前", + "character": { + "id": "lin-xiulan", + "name": "林秀兰" + }, + "sceneText": "赵伯把缺口碗认错了,笑声刚起,林秀兰没有纠着他说,而是把旧照片轻轻转回正面。", + "prompt": "面对一段记得不太清楚的往事,你想怎样陪他们继续认?", + "playerChoices": [ + { + "choiceId": "S01-EM02-A", + "text": "请赵伯按着照片慢慢讲,先说他还认得的人。", + "characterFeedback": "林秀兰把照片推近:“记错一只碗不算什么,人还认得,就接着往下说。”" + }, + { + "choiceId": "S01-EM02-B", + "text": "把饭票、缺口碗和旧照片摆在一起,请两位老人共同核对。", + "characterFeedback": "林秀兰点点票角:“让物件帮着想,不拿物件压人。咱们一件一件对。”" + } + ], + "tableEcho": "旧东西会认错,慢一点核对,人就不会被轻易抹掉。", + "echoCategory": "生活" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C03", + "chapterNumber": 3, + "title": "厂铃一响,饭盆就响", + "year": "1978", + "location": "桂香机械厂集体食堂", + "sceneDescription": "1978年厂铃刚停的集体食堂。工人流向窗口,长桌横贯中景;赵建国端大碗居中,秦师傅守窗口,林秀兰分票夹,小唐在水池边,老吕与长凳工友承担两个前景动作。", + "sceneAlt": "1978年桂香机械厂食堂,长桌长凳与取饭窗口清晰可见", + "narration": "2026年,乐乐指着旧照片问:“爷爷,这一桌怎么都是大碗主食?”赵伯把照片转正:“别拿一张照片说一辈子。先看那天吃什么、下午干什么。”画页翻回1978年。十一点半厂铃一响,搪瓷碗、饭菜票和长凳一齐热闹起来;年轻赵建国干的是重活,端起大碗。小唐卫生员站在水池边,默默看着大家的手和凳子。", + "dialogue": null, + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "理解重体力劳动年代的供应与饭量背景,不嘲笑过去。", + "进食前清洁双手,长凳起身先提醒同伴。" + ], + "cliffhanger": "赵建国把第三碗放上桌:“今天让你看看,劳动骨干怎么吃!”", + "people": [ + { + "instanceId": "c03-old-lv", + "characterId": "old-lv", + "name": "老吕", + "title": "晚班钳工", + "eraLabel": "1978 · 晚班钳工", + "stance": "从车间快步跨进食堂", + "gaze": "看着手中馒头", + "action": "沾机油的手正要抓食物,蓝边缺口碗夹在臂弯", + "prop": "馒头、右缺口搪瓷碗", + "layer": 5, + "position": { + "xPercent": 4, + "yPercent": 24, + "widthPercent": 14, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H09" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H09" + ] + }, + { + "instanceId": "c03-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "1978 · 25岁", + "stance": "挺胸站在长桌中央", + "gaze": "看向一盆白菜土豆和主食", + "action": "托起大碗,让普通菜色和下午的重体力劳动同时进入画面", + "prop": "左缺口白底绿边碗", + "layer": 4, + "position": { + "xPercent": 24, + "yPercent": 20, + "widthPercent": 16, + "heightPercent": 61 + }, + "hotspotIds": [ + "S01-H11" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H11" + ] + }, + { + "instanceId": "c03-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "1978 · 21岁", + "stance": "站在窗口外侧票台", + "gaze": "在饭菜票和晚班名单之间核对", + "action": "左右手使用不同票夹,颜色和用途清楚分开", + "prop": "饭菜票夹、晚班人数单", + "layer": 3, + "position": { + "xPercent": 44, + "yPercent": 17, + "widthPercent": 14, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H12" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H12" + ] + }, + { + "instanceId": "c03-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "1978 · 21岁", + "stance": "站在右侧水池旁,身体微侧", + "gaze": "看向老吕的手和起身长凳", + "action": "擦干双手后才碰记录簿,准备提醒而不抢话", + "prop": "旧棕布卫生包、软皮记录簿", + "layer": 4, + "position": { + "xPercent": 61, + "yPercent": 28, + "widthPercent": 13, + "heightPercent": 52 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "indigo", + "eventIds": [] + }, + { + "instanceId": "c03-worker", + "characterId": "old-worker", + "name": "同凳工友", + "title": "桂香厂旧同事", + "eraLabel": "1978 · 食堂工友", + "stance": "一人将起未起,另一人急忙压住长凳", + "gaze": "彼此看向失衡的凳面", + "action": "起身动作悬在半途,汤碗正向一侧滑", + "prop": "长凳、汤碗", + "layer": 6, + "position": { + "xPercent": 76, + "yPercent": 45, + "widthPercent": 18, + "heightPercent": 40 + }, + "hotspotIds": [ + "S01-H10" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H10" + ] + }, + { + "instanceId": "c03-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "1978 · 24岁", + "stance": "隔着取饭窗口站直", + "gaze": "沿队伍喊下一位", + "action": "握长柄饭勺分饭,只作为年代关系背景", + "prop": "长柄饭勺", + "layer": 1, + "position": { + "xPercent": 85, + "yPercent": 8, + "widthPercent": 10, + "heightPercent": 39 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "jade", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H09", + "eventNumber": 9, + "actorId": "old-lv", + "actorName": "老吕", + "actorInstanceId": "c03-old-lv", + "label": "沾机油的手与馒头", + "actionDescription": "老吕刚从车间赶来,沾机油的手已经伸向馒头。", + "speech": "小唐说:“老吕,先把手清干净再拿吃的,我替你把碗放稳。”老吕说:“差点把车间也吃进去了,我先去洗。”", + "evidence": "工作污物仍清楚留在指缝和掌侧,水池与擦手布就在几步之外。", + "question": "没有清洁双手就直接抓食物,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "工作污物不应直接带到入口食物上,忙和赶时间也不能替代基本清洁。", + "retryHint": "留意馒头会直接入口,而老吕指缝和掌侧仍有机油与工作污物。", + "actionAdvice": "进食前按现场条件把双手清洁并擦干,再接触食物。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H10", + "eventNumber": 10, + "actorId": "old-worker", + "actorName": "老工友", + "actorInstanceId": "c03-worker", + "label": "两人同坐的长凳", + "actionDescription": "同坐长凳的工友突然起身,另一端的人和饭碗同时向后翘起。", + "speech": "起身的工友说:“坐稳了——我先起!”同凳工友说:“这声早半拍,我的汤就保住了。”", + "evidence": "长凳由两人共同压住重心,一人不提醒就离座,可能让同伴失衡摔倒。", + "question": "一个人不提醒同伴就突然起身,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "共坐长凳时突然起身可能使同伴失去平衡,碗里的热食也可能泼出。", + "retryHint": "观察两人共同压住的长凳,在一端突然失去重量时如何翘起。", + "actionAdvice": "起身前先说一声“坐稳了”,确认同伴坐稳后再离座。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H11", + "eventNumber": 11, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c03-zhao", + "label": "白菜、土豆和大盆主食", + "actionDescription": "1978年的画页里,年轻赵建国端着大盆主食;盆边是白菜、土豆,窗外工人正准备下午的重活。", + "speech": "乐乐在2026年问:“爷爷,这一桌怎么都是大碗主食?”赵伯回答:“别拿一张照片说一辈子。那天窗口就这几样,下午还得抬机座。”", + "evidence": "1978年的菜色、供应和重体力劳动都进入同一画面,不能脱离当时条件评价一代人。", + "question": "用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "这张照片里的饭量与当天供应、劳动强度有关;脱离具体日子嘲笑一代人的吃法不准确,也无助于今天调整。", + "retryHint": "别让一张照片替整个年代作证;再看当天的小黑板、车间机座和下午排班。", + "actionAdvice": "先理解年代条件,再结合今天的活动、生活和个人方案讨论份量。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H12", + "eventNumber": 12, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c03-lin", + "label": "饭菜票与晚班人数单", + "actionDescription": "林秀兰左手收饭菜票,右手把抢修和晚班人数单夹进另一只票夹。", + "speech": "林秀兰说:“饭菜票放左边,晚班人数单夹右边。票是票,人是人,回来吃饭的人不能漏。”", + "evidence": "一份用于结算,一份用于留饭;若混为一叠,晚班人数很容易在忙乱中被漏掉。", + "question": "把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "两种凭据用途不同,分开核对才能避免忙乱中漏掉晚班和抢修人员的用餐安排。", + "retryHint": "一份凭据管结算,一份管谁会晚回来;混成一叠容易漏掉晚班人员。", + "actionAdvice": "使用不同票夹或明显标记分开管理,并在关窗前再次核对晚班人数。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境", + "饮食行为", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + } + ], + "sceneAsset": "/package-chapter-03/assets/scenes/gui-xiang-1978.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM03", + "chapterId": "S01-C03", + "title": "问一句累不累", + "triggerPosition": "晚班人数单被看见、第三碗饭的起哄开始之前", + "character": { + "id": "tang-shouan", + "name": "唐守安" + }, + "sceneText": "年轻的小唐从医务室一路跑来,替人洗净水杯、挪好长凳,又退回桌角。满屋都在问谁还能多干一点,没人问他跑这一趟累不累。", + "prompt": "你想怎样给这个不起眼的小唐留一点位置?", + "playerChoices": [ + { + "choiceId": "S01-EM03-A", + "text": "问他:“从医务室跑回来,累不累?先喘口气吧。”", + "characterFeedback": "小唐愣了一下,才笑:“还成。你这一问,我倒想起来该喘口气了。”" + }, + { + "choiceId": "S01-EM03-B", + "text": "往长凳里挪一挪,给他的水杯和饭碗空出位置。", + "characterFeedback": "小唐把水杯放下,小声说:“原来桌边还给我留着地方。”" + } + ], + "tableEcho": "看见一个人,不只看他能干多少,也问他累不累。", + "echoCategory": "成长" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C04", + "chapterNumber": 4, + "title": "劳动骨干的第三碗饭", + "year": "1978", + "location": "桂香机械厂集体食堂", + "sceneDescription": "第三碗饭悬在饭勺和桌面之间。赵建国的嘴硬、扶桌和松皮带落在同一人物上;小唐占右下桌角推水杯,林秀兰从侧后递小碗和空凳,起哄工友围而不堵。", + "sceneAlt": "1978年食堂饭桌旁,工友正为第三碗饭起哄", + "narration": "“人是铁,饭是钢,一顿不吃饿得慌!”工友们起哄比饭量。赵建国嘴上说还能吃,手却已经扶住桌沿;小唐把水杯推过去,只说:“别又急又撑,待会儿弯腰抬东西更难受。”", + "dialogue": "赵建国摆手:“你懂个屁,我可是劳动骨干!”", + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "饭量不能证明劳动能力,真实饥饱不必为面子让路。", + "提醒进食过急过撑,不等于禁止某一种主食。" + ], + "cliffhanger": "下午临时抢修,赵建国带队留下;关窗前,秦师傅却把最后一锅饭全分完了。", + "people": [ + { + "instanceId": "c04-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "1978 · 25岁", + "stance": "半站半倚在长桌前", + "gaze": "先看举起的饭勺,再避开小唐目光", + "action": "带头比饭量;同一人物另一手松皮带、扶桌,仍准备接第三碗", + "prop": "大碗、饭勺、旧皮带", + "layer": 6, + "position": { + "xPercent": 18, + "yPercent": 15, + "widthPercent": 20, + "heightPercent": 69 + }, + "hotspotIds": [ + "S01-H13", + "S01-H14" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H13", + "S01-H14" + ] + }, + { + "instanceId": "c04-workers", + "characterId": "old-worker", + "name": "起哄工友", + "title": "桂香厂旧同事", + "eraLabel": "1978 · 车间工友", + "stance": "围桌探身,留出赵建国的扶桌手", + "gaze": "看饭勺,不看他已经不舒服的动作", + "action": "拍桌喊“第三碗才算数”,笑声把真实饥饱盖住", + "prop": "饭勺、搪瓷缸", + "layer": 4, + "position": { + "xPercent": 3, + "yPercent": 27, + "widthPercent": 15, + "heightPercent": 51 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "", + "markerTone": "jade", + "eventIds": [] + }, + { + "instanceId": "c04-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "1978 · 21岁", + "stance": "坐在右下桌角,不挡人群", + "gaze": "看赵建国扶桌的手而不是饭碗", + "action": "把水杯推过去,只提醒慢一点、别过撑,不禁某一种主食", + "prop": "水杯、旧布包", + "layer": 7, + "position": { + "xPercent": 52, + "yPercent": 43, + "widthPercent": 14, + "heightPercent": 44 + }, + "hotspotIds": [ + "S01-H15" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H15" + ] + }, + { + "instanceId": "c04-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "1978 · 21岁", + "stance": "站在桌侧后,身体为人让开", + "gaze": "看赵建国的脸色", + "action": "放下小碗,拉开一张空凳,给他不丢面子的停顿", + "prop": "小碗、空凳", + "layer": 5, + "position": { + "xPercent": 70, + "yPercent": 22, + "widthPercent": 15, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H16" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H16" + ] + }, + { + "instanceId": "c04-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "1978 · 24岁", + "stance": "在窗口后侧身收锅", + "gaze": "看向即将见底的最后一锅饭", + "action": "把饭分完,为晚班无饭的下一章留下因果", + "prop": "饭锅、木锅盖", + "layer": 1, + "position": { + "xPercent": 86, + "yPercent": 9, + "widthPercent": 9, + "heightPercent": 39 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H13", + "eventNumber": 13, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c04-zhao", + "label": "起哄工友和高举的饭勺", + "actionDescription": "赵建国把饭勺举得像奖杯,带着工友起哄比谁先吃下第三碗。", + "speech": "工友喊:“人是铁,饭是钢,一顿不吃饿得慌!劳动骨干,第三碗才算数!”赵建国把碗一举:“添上!”", + "evidence": "这不是按真实饥饿添饭,而是在用饭量给劳动能力排名。", + "question": "为证明能干,组织“谁吃得多”的比赛,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "饭量不能证明劳动能力;把进食变成排名,会让人为了面子忽略自己的饥饱感受。", + "retryHint": "留意赵建国接第三碗时先看工友的反应,而不是自己的饥饱感受。", + "actionAdvice": "不组织也不参加饭量比赛,不因起哄突然多吃或故意漏餐。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H14", + "eventNumber": 14, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c04-zhao", + "label": "赵伯松开的皮带和扶桌手", + "actionDescription": "赵建国嘴上说不撑,一只手却松皮带、扶桌沿,另一只手还要去接饭勺。", + "speech": "赵建国说:“这算什么,我还能抬。”小唐说:“先坐会儿,把不舒服说出来,也不耽误你是骨干。”", + "evidence": "动作已经暴露明显不舒服,他却准备立刻弯腰参加下午的抬运。", + "question": "已经很撑、仍隐瞒不适并准备马上弯腰抬重物,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "吃得过急过撑可能带来不适;隐瞒感受会让同伴无法及时调整工作或提供帮助。", + "retryHint": "别只听“我没事”,还要看松开的皮带、扶桌的手和接下来要弯腰抬重物。", + "actionAdvice": "停止继续添饭,先坐下休息并说出不适;必要时停止工作并求助。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H15", + "eventNumber": 15, + "actorId": "tang-shouan", + "actorName": "唐守安", + "actorInstanceId": "c04-tang", + "label": "小唐的提醒", + "actionDescription": "小唐只劝“别又急又撑”,工友却故意曲解成他不许大家吃主食。", + "speech": "小唐说:“我不查你几碗,也没说主食不能吃。我只提醒你别吃得又急又撑。”", + "evidence": "他的提醒针对速度、过撑和隐瞒不适,没有给米饭或馒头贴永久红叉。", + "question": "把“别又急又撑”理解成“主食一口都不能吃”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "提醒进食速度和身体感受,不等于禁止某一种主食,更不能把健康建议说成“什么都不能吃”。", + "retryHint": "小唐说的是进食速度、撑和硬扛,并没有禁止某一种主食。", + "actionAdvice": "听清提醒针对的具体行为,再结合本人需要决定是否继续添饭。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-shouan.jpg" + }, + { + "hotspotId": "S01-H16", + "eventNumber": 16, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c04-lin", + "label": "林秀兰放下的小碗和空凳", + "actionDescription": "林秀兰放下一只小碗,推开空凳,先给赵建国一个能停下来的位置。", + "speech": "林秀兰说:“小碗放这儿,空凳也给你拉开。先坐,真不舒服就说,没人因为你停一停,就把骨干牌子摘了。”", + "evidence": "她没有当众揭短,只问是否需要坐一会儿,并把水和通道都留出来。", + "question": "看见同伴扶桌后,先问是否需要坐一会儿,而不是继续起哄,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "给人一个不丢面子的停顿,比继续起哄或当众训斥更容易让他表达真实感受。", + "retryHint": "留意空凳、水和不伤面子的话,给了赵建国一个可以停下来的台阶。", + "actionAdvice": "留出座位和水,先问是否需要休息;出现明显不适时停止相关活动并求助。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + } + ], + "sceneAsset": "/package-chapter-04/assets/scenes/gui-xiang-1978.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM04", + "chapterId": "S01-C04", + "title": "给嘴硬的人一张凳", + "triggerPosition": "第三碗被放下、下午抢修通知贴出之前", + "character": { + "id": "zhao-jianguo", + "name": "赵建国" + }, + "sceneText": "赵建国嘴上还在说“劳动骨干扛得住”,一只手却已经扶住桌沿。周围的笑声没有恶意,却让他更不好意思停下来。", + "prompt": "不拆穿他的逞强,你想怎样接住这一刻?", + "playerChoices": [ + { + "choiceId": "S01-EM04-A", + "text": "把空凳拉近,只说:“先坐稳,下午的活儿还等你拿主意。”", + "characterFeedback": "赵建国嘴上嘟囔“我又没老”,人却坐下了:“那我就坐这一会儿。”" + }, + { + "choiceId": "S01-EM04-B", + "text": "把水杯推过去,低声问:“是真饿,还是大家一喊,你不好意思停?”", + "characterFeedback": "赵建国看了看四周:“都看着呢。你小点声……水给我。”" + } + ], + "tableEcho": "饭能添,面子也要留;劝人停一停,先给他一张凳。", + "echoCategory": "生活" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C05", + "chapterNumber": 5, + "title": "第十五桌给谁留", + "year": "1978", + "location": "熄灯后的桂香食堂", + "sceneDescription": "熄灯后的食堂重新点火。灶火和汤汽在后景,门口是晚班工友;前景赵建国掰馒头,小唐拖凳,林秀兰压名单,秦师傅从灶台走向最后一张长桌。", + "sceneAlt": "半暗的1978年食堂重新点火,蒸汽围住一张留给晚班的长桌", + "narration": "晚班回来,窗口已经熄灯。秦师傅重新点火,林秀兰把值班人数条和饭菜票分开压好;赵建国把半个馒头掰给同伴,小唐拖来长凳。白菜热汤面冒起了白汽。", + "dialogue": null, + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "错过饭点不靠空腹硬扛,要说明情况并解决基本需要。", + "留饭、留座和人数登记,能让来迟的人也被看见。" + ], + "cliffhanger": "众人在新留出的桌旁挤成一张合影,桌沿第一次挂上“15”号铜牌。", + "people": [ + { + "instanceId": "c05-old-lv", + "characterId": "old-lv", + "name": "老吕", + "title": "晚班钳工", + "eraLabel": "1978 · 晚班钳工", + "stance": "疲惫站在已熄灯窗口前", + "gaze": "看空锅后转向车间门", + "action": "准备空腹回去继续当班,又被小唐叫住", + "prop": "右缺口搪瓷碗", + "layer": 5, + "position": { + "xPercent": 4, + "yPercent": 23, + "widthPercent": 14, + "heightPercent": 60 + }, + "hotspotIds": [ + "S01-H17" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H17" + ] + }, + { + "instanceId": "c05-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "1978 · 25岁", + "stance": "坐在长桌左端,肩膀放松下来", + "gaze": "看门口几位晚班同伴", + "action": "把仅剩半个馒头掰开递出去,不按“谁最能干”分", + "prop": "半个馒头", + "layer": 6, + "position": { + "xPercent": 23, + "yPercent": 38, + "widthPercent": 15, + "heightPercent": 47 + }, + "hotspotIds": [ + "S01-H18" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H18" + ] + }, + { + "instanceId": "c05-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "1978 · 21岁", + "stance": "站在窗台与长桌之间", + "gaze": "直看秦师傅,把名单举到灯下", + "action": "分开放好晚班人数单和两枚破损饭菜票", + "prop": "人数单、饭菜票夹", + "layer": 5, + "position": { + "xPercent": 42, + "yPercent": 22, + "widthPercent": 14, + "heightPercent": 58 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "ochre", + "eventIds": [] + }, + { + "instanceId": "c05-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "1978 · 24岁", + "stance": "从重新燃起的灶台转向最后一张长桌", + "gaze": "先看锅中热面,再看门口晚班人数", + "action": "用明确保存的原料现做热汤面,并敲紧十五号铜牌", + "prop": "汤锅、长柄勺、十五号铜牌", + "layer": 7, + "position": { + "xPercent": 60, + "yPercent": 14, + "widthPercent": 16, + "heightPercent": 66 + }, + "hotspotIds": [ + "S01-H19", + "S01-H20" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H19", + "S01-H20" + ] + }, + { + "instanceId": "c05-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "1978 · 21岁", + "stance": "弯身拖来长凳", + "gaze": "看老吕落座的位置", + "action": "摆稳凳脚、递水,不替任何人总结", + "prop": "长凳、水杯", + "layer": 6, + "position": { + "xPercent": 79, + "yPercent": 44, + "widthPercent": 15, + "heightPercent": 40 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H17", + "eventNumber": 17, + "actorId": "old-lv", + "actorName": "老吕", + "actorInstanceId": "c05-old-lv", + "label": "熄灯后的空窗口", + "actionDescription": "晚班工友面对熄灯空窗口,准备什么也不说就继续去干下一段活。", + "speech": "老吕说:“算了,空一顿也能顶。”小唐拦住门:“先把情况说清,硬撑不是安排。”", + "evidence": "饭点已错过、当班还没结束,是否能安全继续不能只靠一句“扛得住”。", + "question": "因为错过饭点就继续空腹硬扛晚班,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "是否能安全继续工作不能只凭意志硬撑;错过饭点后需要结合个人情况及时说明并解决基本需要。", + "retryHint": "“我能顶”只是习惯不麻烦别人,眼前并没有可行的进食与休息安排。", + "actionAdvice": "尽快向现场负责人说明情况,按单位安排和本人需要解决进食与休息。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H18", + "eventNumber": 18, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c05-zhao", + "label": "仅剩的半个馒头", + "actionDescription": "白天饭量最大的赵建国掰开仅剩馒头,却有人提议全给“最能干的”那一位。", + "speech": "老工友说:“就这一个,先给最能干的。”赵建国把馒头掰开:“都忙到这个点了,一人先垫半个。锅马上起。”", + "evidence": "门口回来的是一组晚班工友,按贡献分唯一食物会让其他人的需要消失。", + "question": "把所有食物都留给“最能干的人”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "基本需要不能只按贡献大小分配;每个人的在场和需要都应先被确认。", + "retryHint": "只按贡献大小分配,会把安静的人、陪护的人和来迟的人再次漏掉。", + "actionAdvice": "先确认人数和个人需要,再共同商量临时分配并尽快安排足够食物。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H19", + "eventNumber": 19, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c05-qin", + "label": "重新点火的锅", + "actionDescription": "秦师傅重新点火,用保存条件明确的白菜和面现做热汤面。", + "speech": "秦师傅说:“午间久放的菜不赌,重新做。明天名单报几个人,就按几个人另留原料。”", + "evidence": "他没有翻出午间熟食再热;从第二天起才按报餐人数妥善另留原料。", + "question": "使用保存条件明确的原料,重新现做白菜热汤面,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "保存条件不明或久置的食物不能仅靠再次加热保证安全,现做和完善流程更稳妥。", + "retryHint": "保存条件不明、已经久置的熟食,不会因为“再热一遍”就自动安全。", + "actionAdvice": "不使用保存情况不明的久置食物;以后按报餐人数妥善另留原料,到人后现做。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + }, + { + "hotspotId": "S01-H20", + "eventNumber": 20, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c05-qin", + "label": "第十五桌的空位", + "actionDescription": "秦师傅把最后一张长桌拖近后门,敲紧十五号铜牌,让迟到的人真正坐下。", + "speech": "秦师傅说:“长桌拖灯底下,凳子摆开。十五桌往后别撤,给晚班留着。”", + "evidence": "长凳、热饭、人数单与留座同时出现,支持不是把来迟的人隔到角落站着吃。", + "question": "让晚班、陪护和来迟的人也能坐下吃一口热饭,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "真正的支持不是按疾病、能力或贡献分桌,而是让晚班、陪护和来迟的人也有热饭和座位。", + "retryHint": "这张桌没有设置身份门槛,它让原本没有位置的晚班、陪护和来迟者重新坐下。", + "actionAdvice": "保留清楚的报餐、留饭和留座流程,让来迟的人能坐下并拥有选择。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + } + ], + "sceneAsset": "/package-chapter-05/assets/scenes/gui-xiang-1978.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM05", + "chapterId": "S01-C05", + "title": "为晚归的人留灯", + "triggerPosition": "第十五号铜牌挂好、第一次合影之前", + "character": { + "id": "qin-zhicheng", + "name": "秦志成" + }, + "sceneText": "灶火重新亮起来,热汤的蒸汽慢慢把门口站着的人连成一桌。秦师傅拿着十五号铜牌,还在想怎样才能不再漏掉晚归的人。", + "prompt": "你想和秦师傅一起,为这张桌留下些什么?", + "playerChoices": [ + { + "choiceId": "S01-EM05-A", + "text": "挂好十五号铜牌,再写一张“晚班、陪护到齐再收”的人数单。", + "characterFeedback": "秦师傅把纸压在票夹下:“牌子得挂,人数也得记。人不能再少算。”" + }, + { + "choiceId": "S01-EM05-B", + "text": "先拉开长凳,挨个问晚归的人想吃面、馒头,还是先喝口热汤。", + "characterFeedback": "秦师傅重新揭开锅盖:“先问一声,热饭才算真正给到了人。”" + } + ], + "tableEcho": "有人为晚归的人留了一盏灯。", + "echoCategory": "温暖" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C06", + "chapterNumber": 6, + "title": "木牌翻面的那一天", + "year": "1995", + "location": "从厂食堂改成的桂香饭馆", + "sceneDescription": "1995年木牌正翻面。秦师傅站在门槛中央,一半是职工食堂、一半是桂香饭馆;左右两只手分别递饭票和现金,林秀兰护员工饭,第十五桌完整留在深处。", + "sceneAlt": "1995年桂香饭馆开业,旧食堂格局与新招牌同时存在", + "narration": "木牌从“职工食堂”翻成“桂香饭馆”。一只手还递着旧饭票,另一只手已经拿出现金。秦师傅站在门槛中间:经营办法变了,老工友和员工还能不能坐下吃饭?", + "dialogue": null, + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "经营权不等于房屋所有权,年代证据要看合同和清单。", + "制度改变时先解释、给帮助,不拿不熟悉的人取笑。" + ], + "cliffhanger": "第一桌社会客人进门,指着靠门的第十五桌:“这张给我们拼个大桌。”", + "people": [ + { + "instanceId": "c06-old-worker", + "characterId": "old-worker", + "name": "老工友", + "title": "桂香厂旧同事", + "eraLabel": "1995 · 旧厂职工", + "stance": "站在门槛左侧,手停在半空", + "gaze": "困惑地看自己递出的老饭票", + "action": "按旧习惯付款,听见笑声后想把手缩回去", + "prop": "老饭票、搪瓷缸", + "layer": 4, + "position": { + "xPercent": 4, + "yPercent": 25, + "widthPercent": 14, + "heightPercent": 57 + }, + "hotspotIds": [ + "S01-H22" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H22" + ] + }, + { + "instanceId": "c06-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "1995 · 41岁", + "stance": "跨在门槛中央,两脚分处新旧招牌下", + "gaze": "在合同、饭票、现金和第十五桌之间来回", + "action": "按住承包文件澄清资产边界,也挡住客人指向员工桌的手", + "prop": "承包责任书、资产清单、现金铁盒", + "layer": 6, + "position": { + "xPercent": 28, + "yPercent": 12, + "widthPercent": 19, + "heightPercent": 72 + }, + "hotspotIds": [ + "S01-H21", + "S01-H24" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H21", + "S01-H24" + ] + }, + { + "instanceId": "c06-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "1995 · 38岁", + "stance": "站在后厨口与收银台之间", + "gaze": "看员工轮班纸,再看门外新客", + "action": "给两碗员工饭扣盖,排好错峰时间", + "prop": "员工饭、碗盖、排班纸", + "layer": 5, + "position": { + "xPercent": 54, + "yPercent": 22, + "widthPercent": 15, + "heightPercent": 59 + }, + "hotspotIds": [ + "S01-H23" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H23" + ] + }, + { + "instanceId": "c06-guest", + "characterId": "service-worker", + "name": "第一位社会客人", + "title": "当班服务人员", + "eraLabel": "1995 · 饭馆新客", + "stance": "站在门外右侧,探身看大厅", + "gaze": "指向靠门第十五桌", + "action": "递现金并询问能否拼大桌,等待店家安排", + "prop": "现金、提包", + "layer": 4, + "position": { + "xPercent": 74, + "yPercent": 26, + "widthPercent": 14, + "heightPercent": 55 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "", + "markerTone": "indigo", + "eventIds": [] + }, + { + "instanceId": "c06-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "1995 · 38岁", + "stance": "从告示栏旁经过,只露侧身", + "gaze": "低头看夹在自行车筐里的学习资料", + "action": "在经营冲突外保持边缘,交代合规学习路径", + "prop": "课程讲义、旧布包", + "layer": 1, + "position": { + "xPercent": 87, + "yPercent": 17, + "widthPercent": 9, + "heightPercent": 42 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H21", + "eventNumber": 21, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c06-qin", + "label": "承包经营责任书", + "actionDescription": "秦师傅刚签承包经营责任书,手便下意识按住桌凳清单,像是在说“都是我的”。", + "speech": "秦师傅的手刚按上承包责任书,林秀兰便敲了敲旁边的资产清单:“你承的是经营,别把老厂房也揣进围裙兜里。”", + "evidence": "合同、期限和资产清单仍分开放置,经营权并不自动等于房屋与旧物所有权。", + "question": "说秦师傅签字后,厂房和食堂就全归个人所有,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "经营权、使用安排和资产所有权不是同一件事,需要分别核对合同期限与资产清单。", + "retryHint": "签下经营合同,是否就等于厂房、桌凳和旧物都变成了个人财产?", + "actionAdvice": "把承包文件、期限和资产清单放在一起核对;无法确认的旧物先登记,不擅自处置。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + }, + { + "hotspotId": "S01-H22", + "eventNumber": 22, + "actorId": "old-worker", + "actorName": "老工友", + "actorInstanceId": "c06-old-worker", + "label": "老饭票与新现金的两只手", + "actionDescription": "老工友仍递旧饭票,门外客人同时递现金,旁人忍不住笑他没跟上变化。", + "speech": "老工友把饭菜票往回缩,秦师傅按住旁边的笑声:“规矩刚变,我给您说新办法,别急。”", + "evidence": "付款制度刚变,清楚解释和人工帮助比取笑更能让人顺利完成消费。", + "question": "制度变了就嘲笑仍递饭票的老工友,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "付款制度刚变化,不熟悉新方式很正常;解释和人工帮助能保住人的体面与选择。", + "retryHint": "一个人仍按昨天的办法递饭菜票,是该被取笑,还是该先把新规则说明白?", + "actionAdvice": "先说明现在可用的付款方式,并由工作人员完成一次清楚、耐心的人工协助。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H23", + "eventNumber": 23, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c06-lin", + "label": "碗盖护住的员工饭", + "actionDescription": "林秀兰先给两碗员工饭扣上碗盖,再把错峰轮班纸压在现金盒下。", + "speech": "林秀兰给员工饭扣上碗盖:“饭先盛好,班也排好。零零碎碎尝几口,不能算吃过一顿。”", + "evidence": "员工饭已盛好、吃饭时间有人接班,忙乱中的“先尝两口菜”没有冒充完整一餐。", + "question": "开业再忙也先安排员工轮流坐下吃饭,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "忙乱中尝菜不能长期代替正餐;有饭、有接班时间和可用座位,安排才真正成立。", + "retryHint": "饭盛出来却没人接班、没时间坐下,这顿员工饭真的安排好了吗?", + "actionAdvice": "提前盛好员工饭,写明错峰时间并安排替班,让每个人能坐下完成一餐。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + }, + { + "hotspotId": "S01-H24", + "eventNumber": 24, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c06-qin", + "label": "客人指向第十五桌的手", + "actionDescription": "客人指着第十五桌要拼大桌,秦师傅先挡在桌前,再为客人找别的组合。", + "speech": "客人指向第十五桌,秦师傅挡在前面:“这桌给当班的人坐。我给您并旁边两桌,席面一样摆得开。”", + "evidence": "饭馆对外营业后,这张员工桌依然保持可坐,而不是只挂着“保留”口号。", + "question": "对外营业后仍为员工保留一张能真正坐下吃饭的桌,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "饭馆招待客人,也不能让员工长期失去基本吃饭位置;保留必须在忙时仍可执行。", + "retryHint": "墙上写着“员工桌”,但客人一多就立即撤掉,它还算真正保留吗?", + "actionAdvice": "向客人说明用途并提供其他拼桌方案,同时确保员工桌在营业高峰仍然可坐。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + } + ], + "sceneAsset": "/package-chapter-06/assets/scenes/gui-xiang-1995.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM06", + "chapterId": "S01-C06", + "title": "门牌翻了,位置别丢", + "triggerPosition": "社会客人第一次指向第十五桌、桌子尚未被挪走之前", + "character": { + "id": "qin-zhicheng", + "name": "秦志成" + }, + "sceneText": "“职工食堂”的木牌翻成了“桂香饭馆”。新客人进门,老工友捏着旧饭票,员工的饭还扣在碗盖下面。", + "prompt": "饭馆要往前走,第十五桌怎样继续留下来?", + "playerChoices": [ + { + "choiceId": "S01-EM06-A", + "text": "把十五号桌牌擦亮,留在客人看得见的位置,让老工友和员工都能正常落座。", + "characterFeedback": "秦师傅按住桌沿:“不是给谁开小灶,是谁来了,都别让他站着。”" + }, + { + "choiceId": "S01-EM06-B", + "text": "请林秀兰讲清新的付款办法,也把员工吃饭的班次当众排出来。", + "characterFeedback": "秦师傅收起旧饭票:“新账得讲明白,老情分也不能叫人没地方吃饭。”" + } + ], + "tableEcho": "门牌可以翻面,留给人的座位不能翻没。", + "echoCategory": "生活" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C07", + "chapterNumber": 7, + "title": "桂香饭馆开张", + "year": "1995", + "location": "开张后的桂香饭馆", + "sceneDescription": "开张后的饭馆。少年明远护碗位于左前,赵伯在对面跨桌添饭;唐守安停在门口看表,林秀兰拿饭盒,第十五桌与排班纸在后景保持可用。", + "sceneAlt": "1995年新开张的桂香饭馆,员工饭桌仍靠在墙边", + "narration": "开张忙得脚不沾地。少年明远护住已经盛满的碗,说自己饱了;赵伯却抄起长柄勺:“男孩子能吃才壮!”门口的唐守安问了一句,又看表赶去上课。", + "dialogue": null, + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "爱惜粮食可以从少盛、不够再添开始,不必让孩子吃撑。", + "问了孩子的感受,就要留时间听完。" + ], + "cliffhanger": "三年后的电话问:“五月初八,十桌婚宴,敢不敢接?”秦师傅看向靠墙的第十五桌。", + "people": [ + { + "instanceId": "c07-mingyuan", + "characterId": "tang-mingyuan", + "name": "唐明远", + "title": "中年家人", + "eraLabel": "1995 · 13岁", + "stance": "坐在墙边桌前,双肘收紧", + "gaze": "先看碗,再抬眼看父亲离开的方向", + "action": "把盛满的碗护到胸前,明确说自己已经饱了", + "prop": "大饭碗、作业本", + "layer": 7, + "position": { + "xPercent": 6, + "yPercent": 33, + "widthPercent": 18, + "heightPercent": 52 + }, + "hotspotIds": [ + "S01-H25" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-mingyuan.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H25" + ] + }, + { + "instanceId": "c07-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "1995 · 42岁", + "stance": "从桌对面探身", + "gaze": "只看明远碗底,没有先看孩子表情", + "action": "举长柄勺准备直接添饭,用“男孩子能吃才壮”替孩子决定", + "prop": "长柄添饭勺", + "layer": 6, + "position": { + "xPercent": 30, + "yPercent": 21, + "widthPercent": 18, + "heightPercent": 63 + }, + "hotspotIds": [ + "S01-H26" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H26" + ] + }, + { + "instanceId": "c07-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "1995 · 38岁", + "stance": "一脚已经跨出门,肩膀回转", + "gaze": "先看儿子,随后又落到手表", + "action": "问“还饿不饿”却没等回答完,匆忙赶去上课", + "prop": "手表、旧棕布包、学习资料", + "layer": 5, + "position": { + "xPercent": 54, + "yPercent": 13, + "widthPercent": 15, + "heightPercent": 68 + }, + "hotspotIds": [ + "S01-H27" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H27" + ] + }, + { + "instanceId": "c07-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "1995 · 38岁", + "stance": "站在墙边员工桌侧", + "gaze": "看明远剩饭,也看排班纸上的空档", + "action": "收好少量剩饭,铺平错峰排班纸,让员工桌真正可坐", + "prop": "小饭盒、排班纸", + "layer": 4, + "position": { + "xPercent": 75, + "yPercent": 24, + "widthPercent": 15, + "heightPercent": 59 + }, + "hotspotIds": [ + "S01-H28" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H28" + ] + }, + { + "instanceId": "c07-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "1995 · 41岁", + "stance": "坐在收银台后景", + "gaze": "看第一枚硬币和空白订席单", + "action": "数开张收入,为三年后婚宴电话铺垫", + "prop": "铁皮现金盒、订席单", + "layer": 1, + "position": { + "xPercent": 87, + "yPercent": 8, + "widthPercent": 9, + "heightPercent": 36 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H25", + "eventNumber": 25, + "actorId": "tang-mingyuan", + "actorName": "唐明远", + "actorInstanceId": "c07-mingyuan", + "label": "明远整个人与护碗动作", + "actionDescription": "十三岁的明远双臂护住已经盛满的碗,明明说饱了仍被要求吃到见底。", + "speech": "明远双臂护住满碗:“我真饱了。”林秀兰没有催他见底,只把饭碗先撤下,交给后厨按实际情况处理。", + "evidence": "大碗是成人盛的,孩子已经表达饱足;爱惜粮食可以从一开始少盛做起。", + "question": "孩子说饱了,仍要求他把大碗吃到见底,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "真实饥饱感值得被尊重;成人盛得过多,不应变成孩子必须吃撑的责任。", + "retryHint": "孩子已经明确说饱了,爱惜粮食是否只能靠继续吃到碗底?", + "actionAdvice": "一开始少盛,不够再添;孩子说饱后先停下来,再妥善处理少量剩余食物。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "家庭与儿童", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-mingyuan.jpg" + }, + { + "hotspotId": "S01-H26", + "eventNumber": 26, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c07-zhao", + "label": "桌对面的赵伯与长柄饭勺", + "actionDescription": "赵伯拿长柄饭勺越过桌面,没问明远就又要添一勺。", + "speech": "赵伯的饭勺越过桌面:“男孩子能吃才壮!”明远把碗往怀里收:“我真吃不下了,您别给我添。”", + "evidence": "“男孩子能吃才壮”替代了孩子自己的饥饱感受,也把饭量变成品行考试。", + "question": "用“男孩子能吃才壮”替孩子决定饭量,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "饭量不能证明体格、能力或懂事;关心也需要先询问本人。", + "retryHint": "“我是为你好”能不能代替本人说出的饥饱感受?", + "actionAdvice": "添饭前先问“还要不要”;得到同意再添,拒绝时把饭勺放回去。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H27", + "eventNumber": 27, + "actorId": "tang-shouan", + "actorName": "唐守安", + "actorInstanceId": "c07-tang", + "label": "小唐看表后离开的背影", + "actionDescription": "唐守安问儿子还饿不饿,却边看表边跨出门,答案尚未说完门就合上了。", + "speech": "唐守安问:“还饿不饿?”明远刚抬头,他一看表:“坏了,要迟了。晚上再说。”门合上后,明远才小声说:“我已经饱了。”", + "evidence": "问题本身正确,但没有留出倾听时间,明远的“已经饱了”只落在关门声后。", + "question": "问了“还饿不饿”,却不等孩子答完就走,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "询问只有与等待、回应连在一起,才会让孩子感到自己的表达有效。", + "retryHint": "问题问对了,却没留下听回答的时间,这算真正听见了吗?", + "actionAdvice": "问完先停下手里的安排,让孩子把话说完;暂时不能听时,明确约定并真正回来继续。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-shouan.jpg" + }, + { + "hotspotId": "S01-H28", + "eventNumber": 28, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c07-lin", + "label": "墙边第十五桌与错峰排班纸", + "actionDescription": "林秀兰把第十五桌清空,贴好员工错峰排班纸,让每班都能轮流坐下。", + "speech": "林秀兰把墙边桌清空,压好排班纸:“不是贴个‘员工桌’就算数,谁几点坐下,得有人接他的活。”", + "evidence": "桌子确实可用、时间确实有人替班,关心从一句话变成能执行的安排。", + "question": "开业忙时仍安排员工错峰坐下吃饭,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "可用座位和有人替班的时间同时存在,员工才能真正完成一餐。", + "retryHint": "桌子还在,但没人有时间坐,是否已经实现了“给员工留桌”?", + "actionAdvice": "清空员工桌,写明错峰用餐时间并安排接班人员,营业高峰也照常执行。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + } + ], + "sceneAsset": "/package-chapter-07/assets/scenes/gui-xiang-1995.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM07", + "chapterId": "S01-C07", + "title": "把“我饱了”听完", + "triggerPosition": "唐守安离开、三年后的婚宴电话响起之前", + "character": { + "id": "tang-mingyuan", + "name": "唐明远" + }, + "sceneText": "门已经关上,少年明远仍护着被盛满的大碗。他那句“我已经饱了”,只说给了桌边剩下的人听。", + "prompt": "你愿意怎样把这句话接下去?", + "playerChoices": [ + { + "choiceId": "S01-EM07-A", + "text": "在他旁边坐下,不追问,等他把“我已经饱了”说完。", + "characterFeedback": "明远松开护碗的手:“我不是不懂事,我是真的已经饱了。”" + }, + { + "choiceId": "S01-EM07-B", + "text": "放下添饭勺,问他愿意先少盛一点,还是把没动过的饭另作处理。", + "characterFeedback": "明远把碗推回一点:“那就先少盛。不够的时候,我自己会再添。”" + } + ], + "tableEcho": "问出口的关心,要等到对方把话说完。", + "echoCategory": "成长" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C08", + "chapterNumber": 8, + "title": "四凉八热才叫客气", + "year": "1998", + "location": "桂香饭馆第一场大婚宴", + "sceneDescription": "1998年婚宴将散。左侧圆桌仍过满,右侧主家推回打包盒;前景林秀兰拿复写菜单,深处女炊事员端盒无座,秦师傅正卸铜牌。", + "sceneAlt": "1998年桂香婚宴,过满的转盘与被挤到后厨的员工桌形成对照", + "narration": "圆桌转盘已经摆满,主家仍怕“不够体面”。打包盒被推回,员工桌被挤进后厨。秦师傅说“就这一场”,顺手把十五号铜牌放进现金盒。", + "dialogue": null, + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "体面不等于超量点餐,菜单应说明人数和大致份量。", + "“只借一晚”反复发生,员工没有座位就会变成常态。" + ], + "cliffhanger": "现金盒合上前,铜牌背面的暗红漆与两张破损饭票一闪而过;下一场订席电话又响了。", + "people": [ + { + "instanceId": "c08-host", + "characterId": "wedding-host", + "name": "婚宴主家", + "title": "怕不够体面的客人", + "eraLabel": "1998 · 喜宴主家", + "stance": "坐在转盘外沿又起身招手", + "gaze": "在满桌菜与邻桌客人的眼光之间", + "action": "一面要求继续加菜,一面把打包盒推回去", + "prop": "转盘、加菜单、打包盒", + "layer": 6, + "position": { + "xPercent": 5, + "yPercent": 23, + "widthPercent": 18, + "heightPercent": 61 + }, + "hotspotIds": [ + "S01-H29", + "S01-H30" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H29", + "S01-H30" + ] + }, + { + "instanceId": "c08-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "1998 · 41岁", + "stance": "站在前景菜单与客桌之间", + "gaze": "看只列菜名的复写菜单", + "action": "试图补问大致份量与人数,又被忙乱催着落单", + "prop": "复写菜单、铅笔", + "layer": 7, + "position": { + "xPercent": 31, + "yPercent": 16, + "widthPercent": 15, + "heightPercent": 65 + }, + "hotspotIds": [ + "S01-H31" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H31" + ] + }, + { + "instanceId": "c08-female-cook", + "characterId": "female-cook", + "name": "女炊事员", + "title": "后厨老员工", + "eraLabel": "1998 · 后厨员工", + "stance": "端着饭盒停在被挤窄的门口", + "gaze": "寻找能落碗的座位", + "action": "接过被拒的打包盒,自己却整晚没有地方坐下吃", + "prop": "员工饭盒、打包盒", + "layer": 5, + "position": { + "xPercent": 53, + "yPercent": 29, + "widthPercent": 14, + "heightPercent": 55 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/female-cook.jpg", + "markerTone": "ochre", + "eventIds": [] + }, + { + "instanceId": "c08-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "1998 · 44岁", + "stance": "半蹲在第十五桌短边", + "gaze": "看铜牌,又看仍在响的订席电话", + "action": "卸下铜牌放进现金盒,说“就借这一晚”", + "prop": "十五号铜牌、现金盒", + "layer": 7, + "position": { + "xPercent": 72, + "yPercent": 38, + "widthPercent": 16, + "heightPercent": 47 + }, + "hotspotIds": [ + "S01-H32" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H32" + ] + }, + { + "instanceId": "c08-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "1998 · 41岁", + "stance": "只在婚宴合影边角侧身站立", + "gaze": "看被移向后厨的员工桌", + "action": "不进入本章主对白,身份变化只由不再佩戴卫生员证体现", + "prop": "旧布包", + "layer": 1, + "position": { + "xPercent": 89, + "yPercent": 10, + "widthPercent": 8, + "heightPercent": 36 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H29", + "eventNumber": 29, + "actorId": "wedding-host", + "actorName": "婚宴主家", + "actorInstanceId": "c08-host", + "label": "已经摆满的转盘", + "actionDescription": "婚宴主家看着已经摆满的转盘,仍因怕“显得小气”招手再加几道菜。", + "speech": "主家望着满转盘仍招手:“再添两个硬菜,别让人说小气。”林秀兰问:“先看看十个人已经上了多少?”", + "evidence": "桌面、人数和菜量已经足够,新增菜不是实际需要而是用超量证明体面。", + "question": "只因怕被说小气,再增加几道没人需要的菜,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "招待心意不需要靠超过人数与份量的菜来证明;超量更容易造成浪费。", + "retryHint": "桌面已经摆满,再加菜是实际需要,还是只在替“体面”撑场?", + "actionAdvice": "先核对人数、每份大小、已点品类和实际进度,只补真正缺少的菜。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H30", + "eventNumber": 30, + "actorId": "wedding-host", + "actorName": "婚宴主家", + "actorInstanceId": "c08-host", + "label": "主家推回的打包盒", + "actionDescription": "婚宴主家把员工递来的打包盒推回去,只说带走剩菜“没面子”。", + "speech": "主家把打包盒推回:“喜事哪能拎剩菜走。”女炊事员说:“先问哪些能带,别让面子替食物做决定。”", + "evidence": "半桌菜仍在,是否适合保存需要逐项询问;面子不是拒绝所有安全打包的理由。", + "question": "只因觉得打包没面子,就拒绝带走适合安全保存的剩菜,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "是否适合保存取决于具体食物和存放条件;体面不必靠浪费证明。", + "retryHint": "拒绝打包只是为了面子,是否比逐项确认保存安全更稳妥?", + "actionAdvice": "先向餐厅确认哪些食物适合继续保存;再按该食物相应的保存和复热要求处理,不合适的不要勉强打包。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H31", + "eventNumber": 31, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c08-lin", + "label": "只写菜名的复写菜单", + "actionDescription": "林秀兰拿着只列菜名的复写菜单,想问每道菜大致份量,却被催着赶快落单。", + "speech": "林秀兰把复写菜单铺平,拿铅笔在菜名旁记:“一桌十个人,这盘多大、上几盘,你先给主家说明白。”", + "evidence": "菜单缺少供几人和大致份量,主家无法判断十桌套餐是否已经过量。", + "question": "套餐只列菜名,不说明大致份量和供几人,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "只报菜名,不说明盘量和每桌数量,主家很难判断十桌套餐是否已经过量。", + "retryHint": "复写菜单只写菜名,不说明盘子大小和每桌上几盘,主家能判断是否点多了吗?", + "actionAdvice": "订席时先说清每桌人数、盘量和上菜数量,再决定是否加菜。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + }, + { + "hotspotId": "S01-H32", + "eventNumber": 32, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c08-qin", + "label": "被挤走的员工桌和卸下的铜牌", + "actionDescription": "秦师傅卸下十五号铜牌,把员工桌挤进后厨,嘴上说“就借这一晚”。", + "speech": "秦师傅卸下铜牌:“就借这一晚。”林秀兰停了一拍:“我也说过,就这一场。”", + "evidence": "女炊事员已端盒无座;第一次临时让步没有轮班和替代座位,后来便场场重复。", + "question": "为临时加桌,让员工整晚端碗站着吃,并说“就借这一晚”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "员工整晚端碗站着吃,说明服务安排已经把自己的基本需要挤掉;反复的临时会成为常态。", + "retryHint": "没有替代座位和轮班,“临时借桌”反复发生以后,还只是临时吗?", + "actionAdvice": "保留员工可用座位并安排轮班;确需调整时先落实替代位置和时间,再登记被拆下的旧物。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + } + ], + "sceneAsset": "/package-chapter-08/assets/scenes/gui-xiang-1998.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM08", + "chapterId": "S01-C08", + "title": "热闹也照到后厨", + "triggerPosition": "十五号铜牌落进铁皮盒、下一场订席电话响起之前", + "character": { + "id": "female-cook", + "name": "女炊事员" + }, + "sceneText": "婚宴的笑声还没散,女炊事员端着饭盒站在传菜口。原来属于员工的桌已经被推走,她一时找不到放碗的位置。", + "prompt": "在最忙的时候,你想怎样让她知道自己没有被忘记?", + "playerChoices": [ + { + "choiceId": "S01-EM08-A", + "text": "把后厨唯一的矮凳留给她,约好忙完这一轮就有人来替班。", + "characterFeedback": "女炊事员摸了摸凳面:“凳子在,我就知道这顿饭没把我忘了。”" + }, + { + "choiceId": "S01-EM08-B", + "text": "请秦师傅把自己的饭盒也放到她旁边,最后一道菜后和员工一起坐下。", + "characterFeedback": "女炊事员笑了:“师傅同我们一桌,饭凉一点,也不像是在吃剩下的。”" + } + ], + "tableEcho": "热闹照得到客人,也该照得到端菜的人。", + "echoCategory": "温暖" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C09", + "chapterNumber": 9, + "title": "一杯酒绕了三圈", + "year": "2001", + "location": "桂香饭馆宴席", + "sceneDescription": "2001年宴席四块人物区分开。左前司机老周与钥匙,右后唐守安盖杯端茶,中右赵伯出现不适,下方药盒和个人应急卡归入赵伯区域;林秀兰从侧后递茶。", + "sceneAlt": "2001年饭馆宴席,酒杯、茶杯和后厨旧桌处在不同景深", + "narration": "医院走廊里,医生只说“以后注意管理”。再回到饭桌,别人看赵伯的眼神却先变了:酒杯从司机面前绕到唐守安,再转到他手边。赵伯怕的不是少这一口,而是从此被大家先当成“不能吃的人”。", + "dialogue": "赵伯说:“我怕的不是少吃这一口。我怕的是以后大家一吃饭,第一个想到的就是——赵哥不能吃。”", + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "开车不饮酒,拒酒不等于拒绝感情。", + "聚餐不自行改药;明显不适先停酒、保证安全并按情况求助。" + ], + "cliffhanger": "赵伯稳定后暂坐在后厨旧桌旁。门外响起敲墙声——大厅正在隔新的包间。", + "people": [ + { + "instanceId": "c09-driver", + "characterId": "driver-zhou", + "name": "老周", + "title": "当晚司机", + "eraLabel": "2001 · 当晚司机", + "stance": "坐在靠通道一侧,身体向后避酒", + "gaze": "看桌面车钥匙,再看被推来的杯子", + "action": "用掌心挡住白酒杯,明确一口也不喝", + "prop": "车钥匙、白酒杯、白水", + "layer": 6, + "position": { + "xPercent": 4, + "yPercent": 30, + "widthPercent": 17, + "heightPercent": 53 + }, + "hotspotIds": [ + "S01-H33" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H33" + ] + }, + { + "instanceId": "c09-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "2001 · 44岁", + "stance": "坐在右侧靠门末席", + "gaze": "先看劝酒者,再转向出现不适的赵伯", + "action": "盖住空杯端茶拒酒,随后起身先处理安全而不现场炫技", + "prop": "茶杯、旧布包", + "layer": 7, + "position": { + "xPercent": 28, + "yPercent": 19, + "widthPercent": 16, + "heightPercent": 64 + }, + "hotspotIds": [ + "S01-H34" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H34" + ] + }, + { + "instanceId": "c09-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2001 · 48岁", + "stance": "坐姿开始不稳,一手扶桌", + "gaze": "反应变慢,难以跟上劝酒者", + "action": "药盒与酒杯并置,随后出汗手抖;同一人物承接自行改药与异常识别", + "prop": "药盒、个人应急卡、酒杯", + "layer": 8, + "position": { + "xPercent": 52, + "yPercent": 24, + "widthPercent": 19, + "heightPercent": 60 + }, + "hotspotIds": [ + "S01-H35", + "S01-H36" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H35", + "S01-H36" + ] + }, + { + "instanceId": "c09-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "2001 · 44岁", + "stance": "从侧后跨近桌边", + "gaze": "看赵伯状态与可通行的门口", + "action": "撤走酒杯、递茶并让周围人留出安全空间", + "prop": "茶杯、干净毛巾", + "layer": 5, + "position": { + "xPercent": 76, + "yPercent": 18, + "widthPercent": 14, + "heightPercent": 59 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "indigo", + "eventIds": [] + }, + { + "instanceId": "c09-mingyuan", + "characterId": "tang-mingyuan", + "name": "唐明远", + "title": "中年家人", + "eraLabel": "2001 · 19岁", + "stance": "站在宴席后景替长辈倒酒", + "gaze": "看大人如何劝酒,手势正在迟疑", + "action": "酒壶停在半空,只作代际习惯背景,不遮挡四个热点", + "prop": "酒壶", + "layer": 1, + "position": { + "xPercent": 88, + "yPercent": 7, + "widthPercent": 9, + "heightPercent": 39 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-mingyuan.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H33", + "eventNumber": 33, + "actorId": "driver-zhou", + "actorName": "老周", + "actorInstanceId": "c09-driver", + "label": "司机面前的白酒杯", + "actionDescription": "老周把车钥匙放在桌上,面前仍被推来白酒,旁人劝“就一小口”。", + "speech": "老周按住车钥匙:“车是我开,一口也不碰。给我白水,碰杯照样算数。”", + "evidence": "他当晚要开车,能不能饮酒不应靠杯子大小或自认酒量来侥幸。", + "question": "对要开车的人说“就一小口没事”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "驾车不饮酒,安全不以杯子大小、酒量或同桌起哄来判断。", + "retryHint": "要开车的人能不能用“只喝一小口”或自认酒量好来侥幸?", + "actionAdvice": "明确告诉同桌自己要开车,直接撤下酒杯,换成白水或茶。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮酒与用药安全", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H34", + "eventNumber": 34, + "actorId": "tang-shouan", + "actorName": "唐守安", + "actorInstanceId": "c09-tang", + "label": "靠门末席的唐守安与茶杯", + "actionDescription": "唐守安坐在靠门末席,一手轻盖空酒杯,一手端茶,拒绝后众人不再起哄。", + "speech": "唐守安轻盖空酒杯:“心意我领,酒不喝。茶照样碰。”桌上没有人再把杯子推回来。", + "evidence": "他的茶杯同样参与碰杯,拒酒没有中断情分,桌上也没有人继续用面子施压。", + "question": "清楚拒绝饮酒,其他人也不继续起哄,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "尊重拒绝能同时保住关系与安全;杯中是不是酒,不决定情分深浅。", + "retryHint": "一个人明确拒酒以后,继续拿感情和面子劝他,真的更亲近吗?", + "actionAdvice": "接受对方的拒酒选择,准备白水或茶,碰杯和交谈照常进行。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-shouan.jpg" + }, + { + "hotspotId": "S01-H35", + "eventNumber": 35, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c09-zhao", + "label": "药盒与酒杯", + "actionDescription": "赵伯把药盒和个人应急卡压在酒杯旁,想为了酒局自行停药或改量。", + "speech": "赵伯压低声音:“今儿要陪客,我把药往后挪挪,省得碍事。”唐守安按住药盒:“别自己挪,也别自己加减;先按医生给你的个人方案来。”", + "evidence": "聚餐气氛不能替代个人医嘱;漏服或有疑问需要查看个人方案或联系医生、药师。", + "question": "为了参加酒局,自行停药、补服、加倍、减量或调整胰岛素,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "聚餐气氛不能替代个体化治疗安排;临时自行改药可能带来风险。", + "retryHint": "为了迁就一场酒局,能不能自行停药、补服、加倍、减量或改胰岛素?", + "actionAdvice": "按个人既有医嘱和方案执行;如已漏服或有疑问,查看个人方案并联系医生或药师。", + "medicalEscalation": "如已漏服或有疑问,请查看个人方案,或联系医生、药师。", + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮酒与用药安全" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H36", + "eventNumber": 36, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c09-zhao", + "label": "明显不适的人", + "actionDescription": "赵伯开始出汗、手抖、反应变慢;唐守安已经停酒、移开杯子,并让一两个人留在近处陪伴。", + "speech": "赵伯出汗、手抖、反应变慢。唐守安移开酒杯:“先停酒、留人陪着,别凭样子就说他喝高了。”", + "evidence": "外观不能直接判定醉酒或低血糖;第一步应停酒、移开危险物、保持陪伴,再按意识状态求助。", + "question": "发现出汗、手抖、反应变慢后,先停酒、移开危险物并留下人陪伴,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "这些表现不能仅凭外观确定原因;先停止饮酒、保证安全,并按意识状态采取相应行动。", + "retryHint": "先别急着给原因,看看谁已经把酒杯移开、留下人陪着。", + "actionAdvice": "先停酒、移开危险物、保持陪伴。意识清楚且能够安全吞咽时,按本人已有应急方案处理并尽快联系专业医护;意识不清、抽搐或不能安全吞咽时不喂水、不喂食,立即联系当地院前急救中心或急救电话。", + "medicalEscalation": "意识不清、抽搐或不能安全吞咽时,不喂水、不喂食,立即联系当地院前急救中心/急救电话并持续陪伴。", + "knowledgeTags": [ + "生活场景", + "支持环境", + "饮酒与用药安全" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + } + ], + "sceneAsset": "/package-chapter-09/assets/scenes/gui-xiang-2001.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM09", + "chapterId": "S01-C09", + "title": "别先把我摘出去", + "triggerPosition": "章首30秒记忆片段;四个生活事件完成后进入互动", + "character": { + "id": "zhao-jianguo", + "name": "赵建国" + }, + "openingMemory": { + "maxDurationSeconds": 30, + "title": "三十秒记忆:眼神先变了", + "lines": [ + "医院走廊里,医生只说:“以后注意管理。”画面不展示数值、药物或治疗方案。", + "门一合,切到此后第一次聚餐。有人看见赵建国,筷子停了一下,又下意识把一盘菜往远处挪。", + "赵建国仍坐在原位置,却第一次觉得自己像被从饭桌上轻轻摘了出去。" + ] + }, + "sceneText": "重点不是检查本身,而是从那天以后,别人看他的眼神先变了。", + "prompt": "你愿意怎样让赵伯先说出自己的担心?", + "playerChoices": [ + { + "choiceId": "S01-EM09-A", + "text": "坐到他身边,问:“赵叔,您担心什么?”", + "characterFeedback": "赵伯停了一会儿:“我怕的不是少吃这一口。我怕的是以后大家一吃饭,第一个想到的就是——赵哥不能吃。”" + }, + { + "choiceId": "S01-EM09-B", + "text": "把菜单递回去,问:“今晚您想怎么坐?”", + "characterFeedback": "赵伯把菜单摊开:“那我还坐老位置。该问的我问,该停的,我自己说。”" + } + ], + "tableEcho": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "echoCategory": "成长" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C10", + "chapterNumber": 10, + "title": "后厨里的员工桌", + "year": "2003", + "location": "桂香饭馆后厨", + "sceneDescription": "2003年从传菜口看后厨。旧桌只剩一块空位;女炊事员左前端碗,明远居中被电话拉走,赵伯右侧举添饭勺,秦师傅端饭盒站着,街对面值班牌只作后景。", + "sceneAlt": "2003年桂香饭馆后厨,旧员工桌被菜筐和酒箱占满", + "narration": "旧桌只剩巴掌大一块空位,上面堆着菜筐和酒箱。员工端碗站着吃,明远又被订席电话拉走,冷饭和墙钟一起过了饭点。", + "dialogue": null, + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "长期站着匆忙吃、拖延正餐,需要由排班和可用座位来改变。", + "餐具大小、饭量多少,都不能代表一个人的能力。" + ], + "cliffhanger": "规划红线压过桌脚,最后一张凳子被推走。字幕写着:“五年后,这张图再次展开。”", + "people": [ + { + "instanceId": "c10-female-cook", + "characterId": "female-cook", + "name": "女炊事员", + "title": "后厨老员工", + "eraLabel": "2003 · 后厨员工", + "stance": "端碗站在传菜口左前", + "gaze": "寻找被菜筐占住的空位", + "action": "趁出菜间隙快速站着吃,脚边没有可用凳子", + "prop": "饭碗、抹布", + "layer": 7, + "position": { + "xPercent": 4, + "yPercent": 28, + "widthPercent": 15, + "heightPercent": 57 + }, + "hotspotIds": [ + "S01-H37" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/female-cook.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H37" + ] + }, + { + "instanceId": "c10-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2003 · 50岁", + "stance": "站在桌右侧前倾", + "gaze": "看明远碗里的饭量", + "action": "举大碗和添饭勺,用吃得多替年轻人的能力作证明", + "prop": "大碗、添饭勺", + "layer": 6, + "position": { + "xPercent": 23, + "yPercent": 20, + "widthPercent": 17, + "heightPercent": 64 + }, + "hotspotIds": [ + "S01-H38" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H38" + ] + }, + { + "instanceId": "c10-mingyuan", + "characterId": "tang-mingyuan", + "name": "唐明远", + "title": "中年家人", + "eraLabel": "2003 · 21岁", + "stance": "半坐半起,被电话线拉向另一侧", + "gaze": "看墙钟又转向响起的座机", + "action": "腰身已比十九岁略厚;刚把账本挪开准备吃饭,又因订席电话让冷饭继续拖过饭点", + "prop": "长卷线座机、冷饭、账本", + "layer": 8, + "position": { + "xPercent": 46, + "yPercent": 17, + "widthPercent": 18, + "heightPercent": 68 + }, + "hotspotIds": [ + "S01-H39" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-mingyuan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H39" + ] + }, + { + "instanceId": "c10-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "2003 · 49岁", + "stance": "端着饭盒站在被堆满的旧桌前", + "gaze": "嘴上看员工,眼睛却找不到落碗处", + "action": "说“给自己人留桌”,身体却被酒箱和菜筐逼到通道", + "prop": "饭盒、桌牌", + "layer": 7, + "position": { + "xPercent": 69, + "yPercent": 23, + "widthPercent": 15, + "heightPercent": 61 + }, + "hotspotIds": [ + "S01-H40" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H40" + ] + }, + { + "instanceId": "c10-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "2003 · 46岁", + "stance": "只在街对面窗帘后露过路背影", + "gaze": "看门诊值班牌后继续前行", + "action": "以“唐守安坐诊”牌和旧布包交代合规执业,不进入饭馆冲突", + "prop": "旧布包、值班牌", + "layer": 1, + "position": { + "xPercent": 88, + "yPercent": 10, + "widthPercent": 8, + "heightPercent": 37 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H37", + "eventNumber": 37, + "actorId": "female-cook", + "actorName": "女炊事员", + "actorInstanceId": "c10-female-cook", + "label": "端碗却无座的员工", + "actionDescription": "女炊事员端着碗挤在传菜口,每天趁出菜空隙几口站着吃完。", + "speech": "女炊事员端碗站在传菜口:“等这一锅出完,我再站着扒两口。”桌边明明写着给员工留,却连凳子都没有。", + "evidence": "旧桌被菜筐占满,也没有轮班接手;匆忙站食已从偶发变成长期工作方式。", + "question": "每天都在出菜间隙快速站着吃完,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "长期匆忙、无座和无休息的进食方式,需要从工作安排与可用空间改变,不能只叫个人“注意”。", + "retryHint": "每天都在出菜间隙站着快速吃完,是个人习惯,还是排班和环境没有给人选择?", + "actionAdvice": "清出真实可用的座位,安排替班和完整用餐时间,让员工能坐下完成一餐。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/female-cook.jpg" + }, + { + "hotspotId": "S01-H38", + "eventNumber": 38, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c10-zhao", + "label": "赵伯端来的大碗和添饭勺", + "actionDescription": "赵伯端来大碗和添饭勺,又想用“年轻人能吃才有本事”给明远加饭。", + "speech": "赵伯举起大碗:“男人吃饭,碗小了哪顶事?”明远摊开手掌:“赵叔,我已经饱了。”", + "evidence": "明远已经说饱,餐具大小和饭量都不能代表他的能力或是否肯干活。", + "question": "用碗大、吃得多证明年轻男性有本事,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "餐具和饭量不能代表能力;替人添饭会遮住本人真实饥饱与选择。", + "retryHint": "碗大、吃得多,真的能证明一个年轻人更有本事吗?", + "actionAdvice": "先问本人还要不要;按实际需要选择餐具和份量,不用饭量给能力排名。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H39", + "eventNumber": 39, + "actorId": "tang-mingyuan", + "actorName": "唐明远", + "actorInstanceId": "c10-mingyuan", + "label": "过了饭点仍响的电话、冷饭和墙钟", + "actionDescription": "二十一岁的明远腰身已比少年时略厚;他刚清出吃饭位置,长卷线座机又响,便转身接订席让冷饭继续等。", + "speech": "座机一次次响,明远的饭越放越凉。他终于把听筒放稳:“这通说完,我先把饭吃了。”", + "evidence": "墙钟早过饭点,电话、账本与久坐连续发生;画面记录的是习惯正在累积,不是凭体型诊断疾病。", + "question": "连续久坐接订席,正餐一拖再拖,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "工作、用餐、活动和休息都需要可持续安排;长期拖延不能只靠意志补救。", + "retryHint": "连续久坐接电话、正餐一拖再拖,只靠个人忍着就能长期维持吗?", + "actionAdvice": "给工作设置明确停顿和替接安排,按个人需要保留正常进餐、活动与休息时间。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-mingyuan.jpg" + }, + { + "hotspotId": "S01-H40", + "eventNumber": 40, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c10-qin", + "label": "菜筐、酒箱和被压住的桌牌", + "actionDescription": "秦师傅说“这是给自己人留的桌”,自己端着饭盒却找不到一处能放碗的地方。", + "speech": "秦师傅说“这是给自己人留的桌”,酒箱却压住桌牌,老吕端着碗又从后门退了出去。", + "evidence": "菜筐、酒箱和账本长期压满桌面,口头保留没有形成真实可用的员工座位。", + "question": "口头说“给自己人留桌”,实际长期堆物不能坐,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "环境是否可使用,比墙上标语和口头承诺更能说明一个人的需要有没有被看见。", + "retryHint": "口头说“给自己人留”,实际长期堆物不能坐,这份关心真正落地了吗?", + "actionAdvice": "清空员工桌,安排谁在高峰时替班,并明确这张桌不能长期堆放酒箱和菜筐。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境", + "饮酒与用药安全", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + } + ], + "sceneAsset": "/package-chapter-10/assets/scenes/gui-xiang-2003.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM10", + "chapterId": "S01-C10", + "title": "先让饭盒落桌", + "triggerPosition": "后厨桌面被完全占满、扩建规划红线出现之前", + "character": { + "id": "tang-mingyuan", + "name": "唐明远" + }, + "sceneText": "座机又响了。明远一手抓着长卷线听筒,一手端着已经凉掉的饭盒,桌上连放一只碗的空处都没有。", + "prompt": "你想怎样帮他把这一顿饭重新放回生活里?", + "playerChoices": [ + { + "choiceId": "S01-EM10-A", + "text": "替他记下回电号码,问他想先吃两口,还是先把这通电话说完。", + "characterFeedback": "明远看了一眼墙钟:“那我先吃两口,回头我自己打过去。”" + }, + { + "choiceId": "S01-EM10-B", + "text": "先腾出一块能放饭盒的桌面,陪他等这通电话结束。", + "characterFeedback": "明远把饭盒放稳:“有人等我把电话说完,吃饭就没那么像又一件差事。”" + } + ], + "tableEcho": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "echoCategory": "生活" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C11", + "chapterNumber": 11, + "title": "旧房子要拆了", + "year": "2008", + "location": "翻建前的桂香旧房", + "sceneDescription": "2008年翻建白天主画。展柜在后景,左侧检查旧桌结构,右侧围挡留通道;秦师傅居中悬笔,林秀兰侧后、小满门后、唐守安搬旧物。点击秦师傅才进入夜景保存浮窗。", + "sceneAlt": "2008年翻建前的桂香旧房,桌板、围挡和处置文件等待决定", + "narration": "旧房翻建,秦师傅的笔悬在处置确认上。白天他签了字;夜里,他却把完整桌板包好,把铜牌和钥匙记录一起锁进铁柜。", + "dialogue": null, + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "怀旧不能替代结构安全检查,旧家具先评估再使用。", + "施工要有围挡和清楚通道;历史证据要记录来源。" + ], + "cliffhanger": "十八年后,小满认出抽屉里的旧饭票夹。她没有擅自拿,只第三次问:“现在能开吗?”", + "people": [ + { + "instanceId": "c11-xiaoman", + "characterId": "qin-xiaoman", + "name": "秦小满", + "title": "饭店经营者", + "eraLabel": "2008 · 15岁", + "stance": "站在玻璃展柜与后门之间", + "gaze": "看展板,也看现实中被搬走的座位", + "action": "擦亮“健康、互助”展板,第一次意识到展板不能替代服务", + "prop": "玻璃展柜、展板抹布", + "layer": 5, + "position": { + "xPercent": 5, + "yPercent": 26, + "widthPercent": 14, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H41" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-xiaoman.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H41" + ] + }, + { + "instanceId": "c11-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "2008 · 54岁", + "stance": "居中坐在临时文件桌前", + "gaze": "从老桌结构检查表移到处置签字处", + "action": "白天既舍不得检查又悬笔签字;人物浮窗切到夜里包板、锁柜", + "prop": "结构检查表、签字笔、旧物处置确认", + "layer": 8, + "position": { + "xPercent": 27, + "yPercent": 15, + "widthPercent": 20, + "heightPercent": 69 + }, + "hotspotIds": [ + "S01-H42", + "S01-H44" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H42", + "S01-H44" + ] + }, + { + "instanceId": "c11-builder", + "characterId": "construction-worker", + "name": "施工负责人", + "title": "翻建现场人员", + "eraLabel": "2008 · 翻建现场", + "stance": "站在右侧围挡入口", + "gaze": "检查人员路线和材料堆放区", + "action": "把材料通道与人员通道分开,指向连续围挡", + "prop": "安全帽、围挡清单", + "layer": 6, + "position": { + "xPercent": 53, + "yPercent": 18, + "widthPercent": 16, + "heightPercent": 65 + }, + "hotspotIds": [ + "S01-H43" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "ochre", + "eventIds": [ + "S01-H43" + ] + }, + { + "instanceId": "c11-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "2008 · 51岁", + "stance": "站在秦师傅侧后,不替他拿笔", + "gaze": "看旧桌,再直看秦师傅", + "action": "问“你不是说这桌不动吗”,等待他自己回答", + "prop": "旧账本、钥匙串", + "layer": 5, + "position": { + "xPercent": 72, + "yPercent": 24, + "widthPercent": 14, + "heightPercent": 58 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "indigo", + "eventIds": [] + }, + { + "instanceId": "c11-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "2008 · 51岁", + "stance": "在最右边缘与员工合抬长凳", + "gaze": "看前方通道是否畅通", + "action": "帮助搬安全旧物,不参与秦师傅的决定", + "prop": "长凳、旧布包", + "layer": 3, + "position": { + "xPercent": 87, + "yPercent": 38, + "widthPercent": 10, + "heightPercent": 45 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H41", + "eventNumber": 41, + "actorId": "qin-xiaoman", + "actorName": "秦小满", + "actorInstanceId": "c11-xiaoman", + "label": "玻璃展柜", + "actionDescription": "年轻小满把“健康、互助”展板擦得发亮,实际通道和服务方式却没有改变。", + "speech": "小满说:“这些字要留下。”林秀兰回她:“字留下了,人坐哪儿,也得留下。”", + "evidence": "理念停在玻璃柜与标语里,没有落到可坐座位、菜单信息和人工服务。", + "question": "只把“健康、互助”做成展板,实际服务没有变化,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "旧物和标语能留住记忆,却不能代替现实中的座位、点餐方式和服务动作。", + "retryHint": "展柜保存的是过去,今天来吃饭的人有没有真能使用的选择?", + "actionAdvice": "把理念落实到可使用的座位、清楚菜单、人工帮助和员工轮班,而不是只做陈列。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-xiaoman.jpg" + }, + { + "hotspotId": "S01-H42", + "eventNumber": 42, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c11-qin", + "label": "老旧桌板", + "actionDescription": "秦师傅因舍不得旧桌,想跳过结构检查直接搬进新饭店继续给客人使用。", + "speech": "秦师傅摸着桌沿:“它撑了几十年。”施工负责人说:“先查清还能不能安全撑今天。”", + "evidence": "桌腿和包边已经老化,怀旧不能替代专业检查、修复与安全加固。", + "question": "不检查结构安全,因怀旧就直接继续给客人使用,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "老家具可能存在松动、开裂或承重风险,感情不能替代安全检查。", + "retryHint": "怀旧很珍贵,可桌腿、木板和连接处是否安全,也得先有人确认。", + "actionAdvice": "先停用,由合适的专业人员检查、清洁和加固,再决定用于展示还是实际使用。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + }, + { + "hotspotId": "S01-H43", + "eventNumber": 43, + "actorId": "construction-worker", + "actorName": "施工负责人", + "actorInstanceId": "c11-builder", + "label": "有围挡的临时通行区", + "actionDescription": "施工负责人把材料堆放区与人员必经路线隔开,并用连续围挡留出清楚通道。", + "speech": "施工负责人说:“人走这一边,材料走那一边。看得见的通道,才真能走。”", + "evidence": "围挡、通道箭头和搬运路线彼此分离,安全安排不是只靠口头喊“小心”。", + "question": "施工材料与人员必经路线分开,并保留清楚通道,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "施工现场把材料区和通行区分开,能减少绊倒、碰撞等风险。", + "retryHint": "看看画里的脚手架、材料和门口:人从哪里安全通过?", + "actionAdvice": "设置连续围挡和醒目标识,保持必经路线畅通;图纸和手续只作为年代证据查看。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H44", + "eventNumber": 44, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c11-qin", + "label": "秦师傅整个人与将落未落的签字笔", + "actionDescription": "秦师傅白天悬笔签旧物处置,夜里又独自包好完整桌板、铜牌和钥匙记录。", + "speech": "秦师傅对门后的小满说:“等我肯说的时候,再开。”笔落下了,话却又收了回去。", + "evidence": "签字、包板、锁柜是两个时段;浮窗中的旧物来源记录使保存行为成为证据链而非神秘藏宝。", + "question": "白天签下旧物处置后,夜里仍保存完整桌板、铜牌与钥匙记录,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "旧桌不能冒险继续使用,但有来源记录的物件可以在安全条件下保存,帮助后续核对历史。", + "retryHint": "别把白天和夜里挤成一件事:先看停用是否安全,再看旧物是否留下了来源记录。", + "actionAdvice": "在授权范围内登记旧物来源,分别保存桌板、铜牌、票据和记录;画面用夜景浮窗呈现第二个时间状态。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + } + ], + "sceneAsset": "/package-chapter-11/assets/scenes/gui-xiang-2008.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM11", + "chapterId": "S01-C11", + "title": "不只把桌板收起来", + "triggerPosition": "白天签字结束、切入夜间藏桌板的小画格时", + "character": { + "id": "qin-zhicheng", + "name": "秦志成" + }, + "sceneText": "夜里,秦师傅把完整旧桌板、铜牌和两枚破票包进油布。他没解释,只把每件东西一件件放进铁柜。", + "prompt": "除了保存旧物,你想请秦师傅再留下些什么?", + "playerChoices": [ + { + "choiceId": "S01-EM11-A", + "text": "在旧物记录上写下年份、来由和当年坐过这张桌的人。", + "characterFeedback": "秦师傅把铅笔字写得很慢:“木头留得住,名字也得留住。”" + }, + { + "choiceId": "S01-EM11-B", + "text": "在新饭店规划旁留一句:员工和晚来的人,必须真有可坐的位置。", + "characterFeedback": "秦师傅合上铁柜:“板子收进柜里不算完。新地方得真有人坐。”" + } + ], + "tableEcho": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "echoCategory": "生活" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C12", + "chapterNumber": 12, + "title": "扫码点出一整桌", + "year": "2026", + "location": "重聚宴服务台与餐桌", + "sceneDescription": "回到2026年。左前赵伯与乐乐围手机,中景林秀兰擦出桌板旧字,右前小满守纸单与人工台,明远拿信息卡;小甄只在服务台协助朗读客观信息,秦师傅仍在后厨深景点头开柜。", + "sceneAlt": "2026年重聚宴,手机点餐、纸质菜单和旧桌板同时进入画面", + "narration": "回到现代,赵伯在手机上不断按加号,乐乐在旁边按减号。小满得到爷爷同意后开柜,旧桌板的红漆、孔位、板字和照片终于彼此对上。", + "dialogue": null, + "act": "第三幕 · 今天怎样重新坐下", + "handnote": [ + "扫码之外要保留大字纸单、人工点餐和正常现金收款。", + "菜品信息说明做法和份量,不等于疗效或健康保证。" + ], + "cliffhanger": "秦师傅把稿子重新卷起,只说:“先上菜。”林秀兰看见末行写着“请晚班的人原谅我”。", + "people": [ + { + "instanceId": "c12-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2026 · 73岁", + "stance": "坐在手机点餐台左前", + "gaze": "盯着菜数和加号", + "action": "十人十四菜仍连续按加号,强调自己会用手机但选择仍需判断", + "prop": "点餐手机、盖碗茶", + "layer": 7, + "position": { + "xPercent": 4, + "yPercent": 28, + "widthPercent": 15, + "heightPercent": 57 + }, + "hotspotIds": [ + "S01-H47" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H47" + ] + }, + { + "instanceId": "c12-lele", + "characterId": "lele", + "name": "乐乐", + "title": "会直问的小孙子", + "eraLabel": "2026 · 10岁", + "stance": "站在赵伯椅边,身体向手机探", + "gaze": "看菜数而不是抢走手机", + "action": "用手指停住减号,问人数与份量", + "prop": "菜单份量卡", + "layer": 8, + "position": { + "xPercent": 20, + "yPercent": 43, + "widthPercent": 13, + "heightPercent": 42 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/lele.jpg", + "markerTone": "jade", + "eventIds": [] + }, + { + "instanceId": "c12-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "2026 · 69岁", + "stance": "俯身在服务推车上的旧桌板旁", + "gaze": "看逐渐显出的铅笔字", + "action": "擦去油灰,把红漆、孔位、板字和旧照片摆成可核证的关系", + "prop": "抹布、旧照片、旧桌板", + "layer": 6, + "position": { + "xPercent": 36, + "yPercent": 22, + "widthPercent": 16, + "heightPercent": 61 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "ochre", + "eventIds": [] + }, + { + "instanceId": "c12-xiaoman", + "characterId": "qin-xiaoman", + "name": "秦小满", + "title": "饭店经营者", + "eraLabel": "2026 · 33岁", + "stance": "站在开柜与服务台之间", + "gaze": "先征询爷爷,再看纸单与人工服务是否到位", + "action": "得到同意后开柜留证;同时铺开纸菜单、人工点餐牌和现金盒", + "prop": "饭票夹钥匙、纸菜单、现金盒", + "layer": 8, + "position": { + "xPercent": 56, + "yPercent": 15, + "widthPercent": 17, + "heightPercent": 69 + }, + "hotspotIds": [ + "S01-H45", + "S01-H46" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-xiaoman.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H45", + "S01-H46" + ] + }, + { + "instanceId": "c12-mingyuan", + "characterId": "tang-mingyuan", + "name": "唐明远", + "title": "中年家人", + "eraLabel": "2026 · 44岁", + "stance": "站在右侧餐桌边", + "gaze": "看中性菜品信息卡", + "action": "多年久坐与应酬后腰腹明显丰厚;此刻又把做法和份量误读成健康保证", + "prop": "菜品信息卡、保温杯", + "layer": 7, + "position": { + "xPercent": 78, + "yPercent": 24, + "widthPercent": 15, + "heightPercent": 59 + }, + "hotspotIds": [ + "S01-H48" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-mingyuan.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H48" + ] + }, + { + "instanceId": "c12-xiaozhen", + "characterId": "xiaozhen", + "name": "小甄", + "title": "甄养堂健康客服", + "eraLabel": "2026 · 约28岁", + "stance": "站在服务台侧后,不靠近旧桌证据区", + "gaze": "看大字纸单和需要朗读帮助的来宾", + "action": "只协助朗读做法、份量等客观信息,不替任何人选择,也不解释谜案", + "prop": "大字提示卡、记录夹", + "layer": 3, + "position": { + "xPercent": 69, + "yPercent": 17, + "widthPercent": 9, + "heightPercent": 49 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/xiaozhen.jpg", + "markerTone": "jade", + "eventIds": [] + }, + { + "instanceId": "c12-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "2026 · 72岁", + "stance": "在最深处后厨门边点头", + "gaze": "看小满手中的钥匙", + "action": "允许开柜后仍擦了两次围裙,没有走出门", + "prop": "白围裙、卷稿", + "layer": 1, + "position": { + "xPercent": 89, + "yPercent": 7, + "widthPercent": 8, + "heightPercent": 37 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "ochre", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H45", + "eventNumber": 45, + "actorId": "qin-xiaoman", + "actorName": "秦小满", + "actorInstanceId": "c12-xiaoman", + "label": "饭票夹钥匙和旧桌板", + "actionDescription": "小满第三次先问爷爷,得到同意才用饭票夹钥匙开柜,并逐项拍照核对。", + "speech": "小满说:“这回能开吗?”秦师傅点头。她又说:“一件一件记,别让一个孔替所有证据说话。”", + "evidence": "红漆、孔位、板字、照片、票据与旧物记录共同吻合,且每项都有来源。", + "question": "征得秦师傅同意后开柜,并用红漆、孔位、板字、照片和票据共同核对,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "单一痕迹可能有多种解释,多项来源清楚的证据相互印证更可靠。", + "retryHint": "一只钉孔能说明多少?再看看旁边还有哪些能互相印证的东西。", + "actionAdvice": "分别拍照并记录桌板、铜牌、票据、照片和旧物记录的来源,不用一个孔位证明全部历史。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-xiaoman.jpg" + }, + { + "hotspotId": "S01-H46", + "eventNumber": 46, + "actorId": "qin-xiaoman", + "actorName": "秦小满", + "actorInstanceId": "c12-xiaoman", + "label": "纸质菜单、人工台和现金盒", + "actionDescription": "小满把大字纸菜单铺在服务台,员工站在人工点餐牌旁,现金盒正常打开。", + "speech": "小满说:“会扫码就扫,想看纸单就看纸单;有人在这儿帮,现金也照常收。”", + "evidence": "扫码、纸单、人工点餐并行可用,现金也不是藏起来的“特殊备用”。", + "question": "在扫码之外保留大字纸单、人工点餐,并保持正常现金收款,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "不同人熟悉的操作方式不同,多种真实可用的入口能减少数字生活带来的排除。", + "retryHint": "想想不会扫码、看不清小字,或只想请服务员帮忙的人,能不能顺利点到菜。", + "actionAdvice": "把纸单和人工入口放在显眼处,保持现金正常收付;AI只读客观信息,不作医疗判断。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-xiaoman.jpg" + }, + { + "hotspotId": "S01-H47", + "eventNumber": 47, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c12-zhao", + "label": "十个人十四道菜", + "actionDescription": "赵伯看十个人已点十四道菜,仍因数字“不吉利”连续按加号。", + "speech": "赵伯说:“十四不好听,再来俩。”乐乐按住加号:“先别拿吉利数当饭量,十个人已经点了十四道。”", + "evidence": "他没有查看每道菜份量和已覆盖品类,只让纪念意义替代真实需要。", + "question": "因“十四不好听”就再加两道菜,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "纪念和重视不需要靠超出人数与份量的菜来证明。", + "retryHint": "先别只数菜名,看看每份供几人、有没有重样、十个人实际需要多少。", + "actionAdvice": "先核对人数、份量、品类和已有菜品;真正不够时再补。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H48", + "eventNumber": 48, + "actorId": "tang-mingyuan", + "actorName": "唐明远", + "actorInstanceId": "c12-mingyuan", + "label": "中性菜品信息卡", + "actionDescription": "明远看到“炖、约供二至三人、可选小份”,便把信息卡说成“健康保证”。", + "speech": "明远说:“写得这么健康,多点一份没事。”乐乐问:“它只说怎么做、多大份,咱还没问谁想吃呢,也没说能治病吧?”", + "evidence": "卡片只说明做法和份量,没有疗效承诺,也不能替每个人的身体与治疗安排做决定。", + "question": "看到“烹调方式:炖;本份约供2—3人;可选小份”,就理解成“健康保证”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "做法和份量是帮助选择的客观信息,不代表疗效,也不能替所有人作同一个决定。", + "retryHint": "卡片告诉了哪些事实,又有哪些关于个人身体和治疗的事,它根本没有回答?", + "actionAdvice": "根据人数、份量、整体搭配和个人安排选择;涉及治疗与个体饮食方案时按专业建议执行。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-mingyuan.jpg" + } + ], + "sceneAsset": "/package-chapter-12/assets/scenes/gui-xiang-2026.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM12", + "chapterId": "S01-C12", + "title": "把选择递回去", + "triggerPosition": "菜品信息卡读完、秦师傅解释现代撤桌之前", + "character": { + "id": "zhao-jianguo", + "name": "赵建国" + }, + "sceneText": "手机、AI朗读、大字纸单和人工点餐都摆在桌边。明远想一次替父亲安排妥当,乐乐却停下来等赵伯自己开口。", + "prompt": "信息都在了,最后一步怎样交还给赵伯?", + "playerChoices": [ + { + "choiceId": "S01-EM12-A", + "text": "请赵伯自己选择看纸单、听语音,还是请服务员当面介绍。", + "characterFeedback": "赵伯拿起大字菜单:“让我自己选,才真叫省心。”" + }, + { + "choiceId": "S01-EM12-B", + "text": "请乐乐把信息卡念完,再把菜单放到赵伯手边,等他决定。", + "characterFeedback": "赵伯笑着点点菜单:“乐乐念得明白,最后这一下让我自己点就行。”" + } + ], + "tableEcho": "让人看懂、让人开口,才算把选择递到手里。", + "echoCategory": "温暖" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C13", + "chapterNumber": 13, + "title": "三代人都说“我是为你好”", + "year": "2026", + "location": "重聚宴共同桌与侧桌", + "sceneDescription": "现代共同桌冲突停在动作将变的一刻。乐乐正中护碗,三双公筷从上方撞来;明远右前推菜,小满站侧桌旁握未展开软尺,赵伯从共同桌探身,林秀兰守公筷架。", + "sceneAlt": "2026年共同餐桌,三代人的夹菜动作与隔开的侧桌形成冲突", + "narration": "三双公筷同时伸向乐乐,他赶紧护住碗。赵伯又指着隔开的侧桌,说是专门照顾“病号”。小满没有挪人,先问:“赵伯,您想坐哪儿?”", + "dialogue": null, + "act": "第三幕 · 今天怎样重新坐下", + "handnote": [ + "使用公筷也要尊重本人选择,关心不等于替别人决定。", + "家庭习惯由大人共同创造,不把责任推给孩子。" + ], + "cliffhanger": "秦师傅端起一锅白菜热汤面,终于跨过躲了整晚的后厨门槛。", + "people": [ + { + "instanceId": "c13-lele", + "characterId": "lele", + "name": "乐乐", + "title": "会直问的小孙子", + "eraLabel": "2026 · 10岁", + "stance": "坐在共同桌正中,双臂护碗", + "gaze": "抬头看三双撞在一起的公筷", + "action": "清楚说自己已经饱了,不接受大人轮流添菜", + "prop": "自己的饭碗", + "layer": 9, + "position": { + "xPercent": 8, + "yPercent": 35, + "widthPercent": 17, + "heightPercent": 50 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/lele.jpg", + "markerTone": "cinnabar", + "eventIds": [] + }, + { + "instanceId": "c13-relative", + "characterId": "elder-relative", + "name": "同桌长辈", + "title": "热心夹菜的亲友", + "eraLabel": "2026 · 家庭聚餐", + "stance": "三人从桌对面同时探身", + "gaze": "只看乐乐碗里的空处", + "action": "公筷在碗上方相撞,使用公筷却没有先征求本人意愿", + "prop": "三双公筷", + "layer": 8, + "position": { + "xPercent": 27, + "yPercent": 13, + "widthPercent": 18, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H49" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "jade", + "eventIds": [ + "S01-H49" + ] + }, + { + "instanceId": "c13-mingyuan", + "characterId": "tang-mingyuan", + "name": "唐明远", + "title": "中年家人", + "eraLabel": "2026 · 44岁", + "stance": "坐在乐乐右前侧,身体向孩子倾", + "gaze": "一边阻止赵伯,一边看自己不吃的菜", + "action": "腰腹比青年时明显丰厚;他把菜和管控要求推给孩子,自己的甜饮仍放在手边", + "prop": "成人甜饮、推来的菜盘", + "layer": 8, + "position": { + "xPercent": 48, + "yPercent": 29, + "widthPercent": 17, + "heightPercent": 57 + }, + "hotspotIds": [ + "S01-H51" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-mingyuan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H51" + ] + }, + { + "instanceId": "c13-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2026 · 73岁", + "stance": "从共同桌左侧探身指向远处侧桌", + "gaze": "看自己被挪走的姓名牌", + "action": "用笑话指出“人还没走,牌先走了”,保留被关心者的主动表达", + "prop": "姓名牌、盖碗茶", + "layer": 7, + "position": { + "xPercent": 67, + "yPercent": 25, + "widthPercent": 14, + "heightPercent": 57 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "indigo", + "eventIds": [] + }, + { + "instanceId": "c13-xiaoman", + "characterId": "qin-xiaoman", + "name": "秦小满", + "title": "饭店经营者", + "eraLabel": "2026 · 33岁", + "stance": "站在隔开的侧桌与共同桌之间", + "gaze": "先看姓名牌,再转向赵伯本人", + "action": "从未经询问挪座,转为握住软尺先问本人想坐哪里", + "prop": "姓名牌、尚未展开的软尺", + "layer": 8, + "position": { + "xPercent": 81, + "yPercent": 16, + "widthPercent": 15, + "heightPercent": 66 + }, + "hotspotIds": [ + "S01-H50", + "S01-H52" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-xiaoman.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H50", + "S01-H52" + ] + }, + { + "instanceId": "c13-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "2026 · 69岁", + "stance": "站在公筷架旁侧身让路", + "gaze": "看乐乐而不是看菜盘", + "action": "先问“还要吗”,得到拒绝后把多出的公筷放回架上", + "prop": "公筷架", + "layer": 4, + "position": { + "xPercent": 3, + "yPercent": 9, + "widthPercent": 10, + "heightPercent": 35 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "jade", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H49", + "eventNumber": 49, + "actorId": "elder-relative", + "actorName": "同桌长辈", + "actorInstanceId": "c13-relative", + "label": "三双公筷和乐乐护住的碗", + "actionDescription": "三位长辈的公筷同时伸向乐乐,没问他要不要就在碗上方撞成一团。", + "speech": "赵伯笑:“还排上号了。”乐乐护住碗:“公筷也是筷子,先问我呀!”", + "evidence": "使用公筷解决的是共餐卫生,不会自动取得替别人选菜和份量的同意。", + "question": "不问乐乐就轮流替他夹菜,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "餐具是否公用和本人是否想吃是两件事,关心不能越过明确的饥饱表达。", + "retryHint": "公筷是干净的,可使用公筷,等不等于已经得到本人同意?", + "actionAdvice": "先介绍菜、问“还要不要”,得到同意后再夹,或让孩子自己选择。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "家庭与儿童", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H50", + "eventNumber": 50, + "actorId": "qin-xiaoman", + "actorName": "秦小满", + "actorInstanceId": "c13-xiaoman", + "label": "隔开的侧桌与“病号桌”空白牌面", + "actionDescription": "小满担心赵伯,把他的姓名牌先挪到隔开的侧桌,事前没有问本人。", + "speech": "赵伯探身说:“我人还在这儿,牌先被请走了?”小满的手停在半空。", + "evidence": "侧桌与共同桌有明显距离,“病号桌”把关心变成了未经同意的隔离。", + "question": "因担心赵伯,未经商量就把他安排到隔开的桌上,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "支持不等于把患者排除在共同活动之外,未经询问的特殊安排可能让人感到被贴标签。", + "retryHint": "侧桌东西很齐,可赵伯想不想离开大家,有人问过吗?", + "actionAdvice": "先问本人想坐哪里,在共同桌上调整座位、饮品和服务,让需要与社交都能兼顾。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-xiaoman.jpg" + }, + { + "hotspotId": "S01-H51", + "eventNumber": 51, + "actorId": "tang-mingyuan", + "actorName": "唐明远", + "actorInstanceId": "c13-mingyuan", + "label": "明远手里的甜饮", + "actionDescription": "明远刚提醒乐乐少喝甜饮,自己却仍把甜饮举到嘴边;只要求孩子改变,大人没有先做示范。", + "speech": "明远说:“饮料少喝。”乐乐看着他的瓶子:“爸爸,那咱们一起少喝,好吗?”", + "evidence": "采购、份量和示范都由成人创造;只要求儿童控制,是家庭生活中的双重标准。", + "question": "大人提醒孩子少喝,自己却仍举着甜饮,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "家庭饮食环境由成人共同创造,不能把全部责任推给儿童,也不能用双重标准表达关心。", + "retryHint": "先别只管孩子,看看大人自己手里还拿着什么。", + "actionAdvice": "全家一起调整采购、饮品和份量;不评价孩子体型,让孩子表达饥饱并参与选择。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "家庭与儿童" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-mingyuan.jpg" + }, + { + "hotspotId": "S01-H52", + "eventNumber": 52, + "actorId": "qin-xiaoman", + "actorName": "秦小满", + "actorInstanceId": "c13-xiaoman", + "label": "小满整个人与尚未展开的软尺", + "actionDescription": "小满没有立刻挪赵伯,而是握着尚未展开的软尺,先问他想坐在哪里。", + "speech": "小满问:“赵伯,您想坐哪儿?”赵伯把椅子往里收了收:“我还坐大家旁边。”", + "evidence": "询问本人后,软尺才用于调整共同桌空间;她撕掉标签而不是把人移走。", + "question": "不先挪人,先问赵伯想坐哪里,再调整共同桌,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "先询问本人,再调整环境,能同时保留自主感和实际支持。", + "retryHint": "软尺还没展开,先量桌子,还是先听坐桌的人怎么说?", + "actionAdvice": "先问座位、饮品和服务需要;得到回答后再移动椅子、摆放物品或调整桌面。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-xiaoman.jpg" + } + ], + "sceneAsset": "/package-chapter-13/assets/scenes/gui-xiang-2026.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM13", + "chapterId": "S01-C13", + "title": "先问孩子想不想", + "triggerPosition": "小满撕掉侧桌标签、秦师傅端出热汤面之前", + "character": { + "id": "lele", + "name": "乐乐" + }, + "sceneText": "三双公筷终于都停了下来。乐乐还护着自己的碗,等大人第一次不替他说话。", + "prompt": "这一回,家里人怎样让乐乐自己选?", + "playerChoices": [ + { + "choiceId": "S01-EM13-A", + "text": "大家一起放下筷子,只问:“乐乐,你想先吃哪一样?”", + "characterFeedback": "乐乐松开碗:“我不是不要你们关心,我只是想先把自己的话说完。”" + }, + { + "choiceId": "S01-EM13-B", + "text": "给他一只空的小盘,请他自己选一份,再告诉大家为什么。", + "characterFeedback": "乐乐夹了一小份:“我选好了,也会问你们想不想吃,不用替我夹。”" + } + ], + "tableEcho": "这一次,大人终于听孩子把话说完。", + "echoCategory": "成长" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C14", + "chapterNumber": 14, + "title": "秦师傅最后一道老菜", + "year": "2026", + "location": "重聚宴主桌", + "sceneDescription": "秦师傅扶旧桌板居中承担谜底;左下是热汤面,右下林秀兰整理证据,右上唐守安停在门框,左上赵伯端茶起身。四人视线互相连接,但唐守安不越过秦师傅。", + "sceneAlt": "秦师傅端出白菜热汤面,旧桌板和十五号铜牌重新相遇", + "narration": "秦师傅扶着旧桌板,承认自己每一次都有理由,可这些理由加起来,还是让第十五桌消失了。唐大夫没有坐主位,只在普通座上等他把话说完。", + "dialogue": "秦师傅低声说:“每一次我都有道理。可这些道理加起来,就是我把它弄没了。”", + "act": "第三幕 · 今天怎样重新坐下", + "handnote": [ + "老菜可以讲食材、做法和份量,不能包装成“降糖秘方”。", + "专业身份不替当事人表达;用茶碰杯,同样能表达心意。" + ], + "cliffhanger": "秦师傅问:“十五桌还能回来吗?”小满没有回答,先拿软尺重新量空位。", + "people": [ + { + "instanceId": "c14-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "2026 · 72岁", + "stance": "站在中央展示架前,一手扶住旧桌板", + "gaze": "先看老工友,再低头看热汤面", + "action": "端出朴素老菜,明确不是降糖秘方,并完整承认自己一次次挪走桌子", + "prop": "白菜热汤面、旧桌板、卷稿", + "layer": 9, + "position": { + "xPercent": 26, + "yPercent": 12, + "widthPercent": 21, + "heightPercent": 73 + }, + "hotspotIds": [ + "S01-H53" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H53" + ] + }, + { + "instanceId": "c14-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "2026 · 69岁", + "stance": "半蹲在展示架右下", + "gaze": "逐项看证据来源标签", + "action": "把票、牌、漆、孔位、板字和照片组成可追溯线索链", + "prop": "破损票、铜牌、旧照片、记录卡", + "layer": 8, + "position": { + "xPercent": 51, + "yPercent": 39, + "widthPercent": 16, + "heightPercent": 46 + }, + "hotspotIds": [ + "S01-H54" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H54" + ] + }, + { + "instanceId": "c14-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "2026 · 69岁", + "stance": "停在右上门框,身体朝普通座", + "gaze": "先看秦师傅,随后看末席空位", + "action": "谢绝主位,等待秦师傅说完后才把旧布包放到展示架旁", + "prop": "修补旧布包、老花镜", + "layer": 7, + "position": { + "xPercent": 74, + "yPercent": 9, + "widthPercent": 15, + "heightPercent": 62 + }, + "hotspotIds": [ + "S01-H55" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H55" + ] + }, + { + "instanceId": "c14-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2026 · 73岁", + "stance": "从左侧普通座起身半步", + "gaze": "先招呼唐守安,再回看秦师傅", + "action": "端茶主动碰杯,不再用酒证明旧情分", + "prop": "蓝花盖碗茶", + "layer": 8, + "position": { + "xPercent": 5, + "yPercent": 25, + "widthPercent": 16, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H56" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H56" + ] + }, + { + "instanceId": "c14-mingyuan", + "characterId": "tang-mingyuan", + "name": "唐明远", + "title": "中年家人", + "eraLabel": "2026 · 44岁", + "stance": "坐在后景父亲将走向的末席旁", + "gaze": "看父亲不再看表", + "action": "把未说完的“我已经饱了”留到四热点后的父子近景", + "prop": "筷子、保温杯", + "layer": 2, + "position": { + "xPercent": 88, + "yPercent": 45, + "widthPercent": 9, + "heightPercent": 35 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-mingyuan.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H53", + "eventNumber": 53, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c14-qin", + "label": "白菜热汤面", + "actionDescription": "秦师傅端出白菜热汤面,有人想把这道老菜包装成“祖传降糖面”。", + "speech": "秦师傅说:“就是白菜、面和一锅热汤。讲做法、讲份量,别给它封神。”", + "evidence": "它是当年给晚班工友的一顿热饭,不是治疗,也不会对所有人产生同样作用。", + "question": "因为是老菜,就宣传成“祖传降糖面”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "一道菜不能治疗糖尿病,也不会对所有人产生相同作用;怀旧不能变成疗效承诺。", + "retryHint": "这锅面能唤起记忆,可一道菜能不能代替治疗、适合所有人?", + "actionAdvice": "只客观介绍食材、做法和份量;具体怎么选按个人情况、既有方案和专业建议。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + }, + { + "hotspotId": "S01-H54", + "eventNumber": 54, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c14-lin", + "label": "两枚破损票、铜牌、红漆与桌板", + "actionDescription": "林秀兰把破损票、铜牌、红漆桌板、旧照片和记录按来源逐件排开。", + "speech": "林秀兰说:“每件东西只说自己知道的那一段,合起来,故事才站得稳。”", + "evidence": "多项证据相互支持同一历史,但仍需结合发言稿和当事人叙述,不能只靠孔位定案。", + "question": "把票据、铜牌、暗红漆、孔位、板字和照片一起核对,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "多项来源清楚的证据共同指向同一段历史,比单凭一处痕迹可靠。", + "retryHint": "别只盯着一只钉孔,看看每件物品的来源和时间能不能互相接上。", + "actionAdvice": "逐项登记来源和时间,再结合发言稿、调台记录与当事人叙述完成证据链。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + }, + { + "hotspotId": "S01-H55", + "eventNumber": 55, + "actorId": "tang-shouan", + "actorName": "唐守安", + "actorInstanceId": "c14-tang", + "label": "门框中的唐大夫", + "actionDescription": "唐守安在门框停下,谢绝主位,走向普通座,让秦师傅把认错的话自己说完。", + "speech": "赵伯喊:“给唐大夫留个座。”唐守安摆手:“普通座就好,先听老秦把话说完。”", + "evidence": "专业身份没有夺走当事人的表达和责任;他只把旧布包放在展示架旁。", + "question": "大家请他坐主位,他选择普通座并让秦师傅自己说完,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "专业身份不应取代当事人的责任和表达,陪伴也不等于替别人发言。", + "retryHint": "唐大夫能帮助理解健康问题,可这次该由谁承担、谁把话说完?", + "actionAdvice": "让当事人完整陈述;专业者坐在同桌提供支持,只有需要时再说明边界或转介。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-shouan.jpg" + }, + { + "hotspotId": "S01-H56", + "eventNumber": 56, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c14-zhao", + "label": "赵伯的茶杯", + "actionDescription": "赵伯把茶杯转半圈主动碰杯,也不再劝别人换成酒。", + "speech": "赵伯说:“这回我当个不劝酒、不硬撑的骨干。老伙计,茶也算一杯。”", + "evidence": "茶和白水同样承载祝福,桌上人的选择被接受,没有用杯中酒精衡量情分。", + "question": "用茶参加碰杯,也不再要求别人喝酒,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "拒绝饮酒不等于拒绝关系,心意不取决于杯子里是不是酒。", + "retryHint": "看的是杯中酒,还是人与人之间的心意?", + "actionAdvice": "提前准备白水或茶,碰杯前接受每个人的饮品选择,不追问、不起哄。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + } + ], + "sceneAsset": "/package-chapter-14/assets/scenes/gui-xiang-2026.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM14", + "chapterId": "S01-C14", + "title": "这次不看表", + "triggerPosition": "四个正式互动完成、主画翻到父子安静小画格时", + "character": { + "id": "tang-shouan", + "name": "唐守安", + "secondaryCharacter": "唐明远" + }, + "sceneText": "唐守安和明远同高坐下。那只曾让他问完就走的手表,还亮在桌边。", + "prompt": "唐守安怎样让儿子把那句旧话真正说完?", + "playerChoices": [ + { + "choiceId": "S01-EM14-A", + "text": "把手表翻向桌面,什么也不解释,安静等明远说完。", + "characterFeedback": "明远说:“那年我不是故意剩饭。我说过我饱了,可你没听完。”唐守安只回答:“我在听。”" + }, + { + "choiceId": "S01-EM14-B", + "text": "先承认:“我以前问得太快了。这次你慢慢说。”", + "characterFeedback": "唐守安低声说:“我记得我问过,今天才知道我没等答案。”明远看着他:“那你先别改我这句话。”" + } + ], + "tableEcho": "会解决问题的人,也可以学着先听一会儿。", + "echoCategory": "成长" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C15", + "chapterNumber": 15, + "title": "第十五桌重新开席", + "year": "2026", + "location": "桂香大饭店原址", + "sceneDescription": "回到第一章完全相同的固定轴线机位:第十五桌桌脚落回四个压痕,旧桌板暗红铁包边与原轴线对齐。秦师傅坐在靠门桌首等老吕入席,小满拉椅;赵伯举茶,明远夹菜前先问,唐守安坐末席倾听。小甄和服务员把份量、白水与打包提示变成可点击人物行动;老吕带来的缺口碗只作纪念,不再盛食物。", + "sceneAlt": "与第一章同一固定机位,第十五桌落回原桌脚压痕,晚班员工与三代人共同围桌", + "narration": "同一固定机位里,第十五桌的桌脚落回第一章四个压痕的位置,旧桌板的暗红铁包边也重新对齐。晚班员工刚进门,小满已经拉开椅子;明远的筷子停在半空,先问乐乐还要不要。", + "dialogue": "秦师傅看着老吕带来的缺口碗:“还留着呢?”老吕把它放进透明托架:“留着照相。盛饭,咱用新的。”", + "act": "第三幕 · 今天怎样重新坐下", + "handnote": [ + "清楚的份量、人数和饮品信息,让每个人按需要选择。", + "健康支持留在共同桌上,不隔离患者、不神化食物、不责怪孩子。" + ], + "cliffhanger": "快门按下,旧铜牌“15”重新固定。传菜口还有人探头,小满又拉开几把椅子:“别站着了,热饭给你们留着呢。”", + "people": [ + { + "instanceId": "c15-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "2026 · 72岁", + "stance": "坐在第十五桌靠门桌首,身体朝向门口", + "gaze": "第一眼认出老吕手里的缺口碗,再看桌边空椅", + "action": "把完好碗筷推到空位前,等老吕坐稳才招呼开席", + "prop": "长柄饭勺、十五号铜牌、完好碗筷", + "layer": 7, + "position": { + "xPercent": 1, + "yPercent": 14, + "widthPercent": 11, + "heightPercent": 54 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "cinnabar", + "eventIds": [] + }, + { + "instanceId": "c15-xiaozhen", + "characterId": "xiaozhen", + "name": "小甄", + "title": "甄养堂健康客服", + "eraLabel": "2026 · 约28岁", + "stance": "站在邻里桌左前的服务台旁", + "gaze": "看三种餐盘和伸手取水的人", + "action": "摆出真实可选的普通、小、半份,并把白水放在容易取得处", + "prop": "三种份量餐盘、白水壶、记录夹", + "layer": 8, + "position": { + "xPercent": 13.5, + "yPercent": 20, + "widthPercent": 11, + "heightPercent": 64 + }, + "hotspotIds": [ + "S01-H57", + "S01-H58" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/xiaozhen.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H57", + "S01-H58" + ] + }, + { + "instanceId": "c15-mingyuan", + "characterId": "tang-mingyuan", + "name": "唐明远", + "title": "中年家人", + "eraLabel": "2026 · 44岁", + "stance": "坐在长桌左中,公筷停在半空", + "gaze": "先看乐乐的脸,再看孩子自己指的菜", + "action": "夹菜前先问,把公筷递给乐乐自己选择", + "prop": "公筷、保温杯", + "layer": 9, + "position": { + "xPercent": 26, + "yPercent": 33, + "widthPercent": 11, + "heightPercent": 52 + }, + "hotspotIds": [ + "S01-H59" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-mingyuan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H59" + ] + }, + { + "instanceId": "c15-xiaoman", + "characterId": "qin-xiaoman", + "name": "秦小满", + "title": "饭店经营者", + "eraLabel": "2026 · 33岁", + "stance": "站在长桌右侧拉开一把椅子", + "gaze": "看刚忙完晚市、准备进门的员工", + "action": "让来晚的人先坐下,把邻里桌从展品变成真正使用的座位", + "prop": "椅子、软尺收纳袋", + "layer": 8, + "position": { + "xPercent": 38.5, + "yPercent": 20, + "widthPercent": 11, + "heightPercent": 64 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/qin-xiaoman.jpg", + "markerTone": "indigo", + "eventIds": [] + }, + { + "instanceId": "c15-worker", + "characterId": "service-worker", + "name": "饭店员工", + "title": "当班服务人员", + "eraLabel": "2026 · 晚市当班", + "stance": "端着可分类的打包盒站在传菜口", + "gaze": "看顾客留下的菜和保存提示", + "action": "逐项询问是否打包,说明哪些适合保存及相应处理方式", + "prop": "打包盒、保存提示卡", + "layer": 7, + "position": { + "xPercent": 51, + "yPercent": 25, + "widthPercent": 11, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H60" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H60" + ] + }, + { + "instanceId": "c15-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2026 · 73岁", + "stance": "坐在桌的右中位置举茶", + "gaze": "看新进门的晚班工友", + "action": "用茶代酒,不劝酒、不硬撑,也不替别人盛饭", + "prop": "蓝花盖碗茶", + "layer": 8, + "position": { + "xPercent": 63.5, + "yPercent": 32, + "widthPercent": 11, + "heightPercent": 52 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "jade", + "eventIds": [] + }, + { + "instanceId": "c15-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "2026 · 69岁", + "stance": "安静坐在最右末席", + "gaze": "看明远说话,不再瞥手表", + "action": "把手表翻向桌面,完整听儿子和乐乐说完", + "prop": "旧布包、翻面的手表", + "layer": 5, + "position": { + "xPercent": 76, + "yPercent": 34, + "widthPercent": 11, + "heightPercent": 50 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "ochre", + "eventIds": [] + }, + { + "instanceId": "c15-old-lv", + "characterId": "old-lv", + "name": "老吕", + "title": "晚班钳工", + "eraLabel": "2026 · 老工友", + "stance": "从门口与晚班员工一起进入", + "gaze": "看重新固定的十五号铜牌", + "action": "双手托着旧缺口碗走向座位;合影后把碗放回安全托架,正式用餐改用完好餐具", + "prop": "纸请柬、左侧缺口搪瓷碗", + "layer": 2, + "position": { + "xPercent": 88.5, + "yPercent": 12, + "widthPercent": 10, + "heightPercent": 44 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "", + "markerTone": "indigo", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H57", + "eventNumber": 57, + "actorId": "xiaozhen", + "actorName": "小甄", + "actorInstanceId": "c15-xiaozhen", + "label": "普通份、小份、半份", + "actionDescription": "小甄把普通份、小份、半份三种实物餐盘并排摆好,并标明建议用餐人数。", + "speech": "小甄说:“先看几个人、每份多大。少点不够再添,比猜一桌需要多少更踏实。”", + "evidence": "三种份量都能真实下单,不是菜单上写了“小份”却现场无法选择。", + "question": "菜单提供多种份量并写建议用餐人数,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "清楚的份量信息和多种规格让每个人更容易按人数、饥饱和个人安排选择。", + "retryHint": "选择多是不是等于要求所有人都吃小份?再看看它真正增加了什么。", + "actionAdvice": "点餐前先看份量和建议人数;选择适合当下需要的规格,不把小份变成统一要求。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/xiaozhen.jpg" + }, + { + "hotspotId": "S01-H58", + "eventNumber": 58, + "actorId": "xiaozhen", + "actorName": "小甄", + "actorInstanceId": "c15-xiaozhen", + "label": "白水壶与分开的其他饮品区", + "actionDescription": "小甄把白水壶放在伸手可取处,酒和甜饮另行陈列,等本人询问再选择。", + "speech": "小甄说:“白水先放手边,其他饮品看清信息、问过本人,再决定要不要。”", + "evidence": "白水成为容易取得的默认选项,其他饮品没有被自动摆到每个人面前。", + "question": "默认让白水容易取得,酒和甜饮由本人选择,不替所有人自动摆上,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "让白水容易取得、其他饮品由本人选择,既方便也能减少强推和起哄。", + "retryHint": "桌上默认出现什么,会不会悄悄影响每个人的选择?", + "actionAdvice": "先提供白水;需要其他饮品时询问本人,并客观查看配料和添加糖等信息。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/xiaozhen.jpg" + }, + { + "hotspotId": "S01-H59", + "eventNumber": 59, + "actorId": "tang-mingyuan", + "actorName": "唐明远", + "actorInstanceId": "c15-mingyuan", + "label": "明远停在半空的筷子", + "actionDescription": "明远的公筷停在半空,没有直接落进乐乐碗里,而是先问还要不要。", + "speech": "明远问:“这个还要吗?”乐乐点头:“要一点,我自己夹。”明远把公筷递过去。", + "evidence": "他把关心改成询问;得到同意再夹,或者让乐乐自己选择,都保留了本人决定。", + "question": "夹菜前先问乐乐还要不要,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "先询问能尊重饥饱和自主选择,也让关心不再变成压力。", + "retryHint": "关心是先把菜放进碗里,还是先给对方回答的机会?", + "actionAdvice": "先问“还要吗”“想自己夹吗”;得到同意后再帮忙,或让本人自己选择。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "家庭与儿童" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-mingyuan.jpg" + }, + { + "hotspotId": "S01-H60", + "eventNumber": 60, + "actorId": "service-worker", + "actorName": "饭店员工", + "actorInstanceId": "c15-worker", + "label": "打包盒与保存提示", + "actionDescription": "饭店员工没有把所有剩菜一股脑装盒,而是先逐项说明哪些适合保存和怎样处理。", + "speech": "员工问:“这些要带走吗?我先把能不能保存、怎么再加热给您说清。”", + "evidence": "不同食物的保存条件不同;合理点餐优先,需要打包时还要询问本人和餐厅安全提示。", + "question": "所有剩菜不分情况都必须打包,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "不同食物的保存条件和安全风险不同,打包不能替代合理点餐,也不能忽略食品安全。", + "retryHint": "珍惜食物很重要,可每一种剩菜都适合继续保存吗?", + "actionAdvice": "先按需点餐;需要打包时询问餐厅是否适合保存、怎样冷藏和再加热,不适合的不要勉强带走。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + } + ], + "sceneAsset": "/package-chapter-15/assets/scenes/gui-xiang-2026.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM15", + "chapterId": "S01-C15", + "title": "人坐回来了", + "triggerPosition": "四项正式互动完成、最终合影快门落下之前", + "character": { + "id": "old-lv", + "name": "老吕", + "secondaryCharacter": "秦志成" + }, + "visualClosure": { + "camera": "回到第一章完全相同的固定轴线机位", + "tablePosition": "第十五桌桌脚落回第一章四个新压痕的位置", + "objectAlignment": "旧桌板暗红铁包边与同一桌脚轴线对应", + "peopleClosure": "赵伯不再站在十四桌与十六桌之间;第十五桌已经坐满,仍为晚班员工留着一把可拉开的椅子", + "bowlSafety": "缺口搪瓷碗只作纪念物,不盛放食物;正式用餐使用完好餐具" + }, + "sceneText": "同一机位里,旧桌板的暗红铁包边对准第一章的桌脚压痕。第十五桌坐满了人,老吕也带来了那只缺口搪瓷碗。", + "prompt": "这只旧碗怎样留在今天的第十五桌边?", + "playerChoices": [ + { + "choiceId": "S01-EM15-A", + "text": "把旧碗放进透明纪念托架,在老吕座位前另摆一只完好的新碗。", + "characterFeedback": "秦师傅看着旧碗:“还留着呢?”老吕把它放稳:“留着照相。吃饭,我用新的。”" + }, + { + "choiceId": "S01-EM15-B", + "text": "请老吕拿着旧碗拍完合影,再把它送回展柜,用完好餐具一起吃饭。", + "characterFeedback": "秦师傅替他托住碗底:“先送回展柜?”老吕点头:“新碗盛饭,旧碗留个念想。”" + } + ], + "tableEcho": "桌子回来了,人也都坐回来了。", + "echoCategory": "温暖" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + } + ] +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/audio/S01-C01-P01.daa8f04a2627.mp3 b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/audio/S01-C01-P01.daa8f04a2627.mp3 new file mode 100644 index 0000000..c575e6f Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/audio/S01-C01-P01.daa8f04a2627.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/audio/S01-C01-P02.7774981b1878.mp3 b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/audio/S01-C01-P02.7774981b1878.mp3 new file mode 100644 index 0000000..8dc9721 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/audio/S01-C01-P02.7774981b1878.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/audio/S01-C01-P03.0f21daa7eccb.mp3 b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/audio/S01-C01-P03.0f21daa7eccb.mp3 new file mode 100644 index 0000000..4dab80f Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/audio/S01-C01-P03.0f21daa7eccb.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/audio/S01-C01-P04.ed65c367d761.mp3 b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/audio/S01-C01-P04.ed65c367d761.mp3 new file mode 100644 index 0000000..2e8b392 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/audio/S01-C01-P04.ed65c367d761.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/player-art/S01-C01-P01-title-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/player-art/S01-C01-P01-title-v1.jpg new file mode 100644 index 0000000..30a4e90 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/player-art/S01-C01-P01-title-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/player-art/S01-C01-P02-missing-table-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/player-art/S01-C01-P02-missing-table-ensemble-v1.jpg new file mode 100644 index 0000000..2e8b92e Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/player-art/S01-C01-P02-missing-table-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/player-art/S01-C01-P03-H01-scan-only-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/player-art/S01-C01-P03-H01-scan-only-v1.jpg new file mode 100644 index 0000000..99e297d Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/player-art/S01-C01-P03-H01-scan-only-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/player-art/S01-C01-P04-H02-extra-dish-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/player-art/S01-C01-P04-H02-extra-dish-v1.jpg new file mode 100644 index 0000000..872fe84 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/assets/player-art/S01-C01-P04-H02-extra-dish-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/data/audioPages.js b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/data/audioPages.js new file mode 100644 index 0000000..b8d55c7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/data/audioPages.js @@ -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', + }), +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/pages/player/player.js b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/pages/player/player.js new file mode 100644 index 0000000..e48ec1e --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/pages/player/player.js @@ -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() + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/pages/player/player.json b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/pages/player/player.json new file mode 100644 index 0000000..ea2c8c0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/pages/player/player.json @@ -0,0 +1,3 @@ +{ + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/pages/player/player.wxml b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/pages/player/player.wxml new file mode 100644 index 0000000..573995d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/pages/player/player.wxml @@ -0,0 +1,63 @@ + + + + + 第01回 · 有声夹页 + {{pageNumber}}/8 + + + + + + + 桂香故事 + + + + {{title}} + {{caption}} + + + + {{currentLabel}} / {{durationLabel}} + + + + + + + + + + 语速 + + + + {{error}} + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/pages/player/player.wxss b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/pages/player/player.wxss new file mode 100644 index 0000000..59cbace --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-a/pages/player/player.wxss @@ -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; } +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/audio/S01-C01-P05.210005d24e32.mp3 b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/audio/S01-C01-P05.210005d24e32.mp3 new file mode 100644 index 0000000..8809605 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/audio/S01-C01-P05.210005d24e32.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/audio/S01-C01-P06.5e69ed4c17f3.mp3 b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/audio/S01-C01-P06.5e69ed4c17f3.mp3 new file mode 100644 index 0000000..9cf2aba Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/audio/S01-C01-P06.5e69ed4c17f3.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/audio/S01-C01-P07.a68bc0cb8ecc.mp3 b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/audio/S01-C01-P07.a68bc0cb8ecc.mp3 new file mode 100644 index 0000000..62ac745 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/audio/S01-C01-P07.a68bc0cb8ecc.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/audio/S01-C01-P08.1c44eff9d71f.mp3 b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/audio/S01-C01-P08.1c44eff9d71f.mp3 new file mode 100644 index 0000000..d55301e Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/audio/S01-C01-P08.1c44eff9d71f.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/player-art/S01-C01-P05-H03-four-imprints-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/player-art/S01-C01-P05-H03-four-imprints-v1.jpg new file mode 100644 index 0000000..dcb42b9 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/player-art/S01-C01-P05-H03-four-imprints-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/player-art/S01-C01-P06-H04-unsaid-speech-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/player-art/S01-C01-P06-H04-unsaid-speech-v1.jpg new file mode 100644 index 0000000..7081936 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/player-art/S01-C01-P06-H04-unsaid-speech-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/player-art/S01-C01-P07-EM01-call-zhao-in-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/player-art/S01-C01-P07-EM01-call-zhao-in-v1.jpg new file mode 100644 index 0000000..ce5849e Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/player-art/S01-C01-P07-EM01-call-zhao-in-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/player-art/S01-C01-P08-cliffhanger-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/player-art/S01-C01-P08-cliffhanger-v1.jpg new file mode 100644 index 0000000..836160b Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/assets/player-art/S01-C01-P08-cliffhanger-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/data/audioPages.js b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/data/audioPages.js new file mode 100644 index 0000000..5f6880f --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/data/audioPages.js @@ -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', + }), +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/pages/player/player.js b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/pages/player/player.js new file mode 100644 index 0000000..e48ec1e --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/pages/player/player.js @@ -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() + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/pages/player/player.json b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/pages/player/player.json new file mode 100644 index 0000000..ea2c8c0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/pages/player/player.json @@ -0,0 +1,3 @@ +{ + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/pages/player/player.wxml b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/pages/player/player.wxml new file mode 100644 index 0000000..573995d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/pages/player/player.wxml @@ -0,0 +1,63 @@ + + + + + 第01回 · 有声夹页 + {{pageNumber}}/8 + + + + + + + 桂香故事 + + + + {{title}} + {{caption}} + + + + {{currentLabel}} / {{durationLabel}} + + + + + + + + + + 语速 + + + + {{error}} + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/pages/player/player.wxss b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/pages/player/player.wxss new file mode 100644 index 0000000..59cbace --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-c01-b/pages/player/player.wxss @@ -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; } +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-player/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-audio-player/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-player/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-player/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-audio-player/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-player/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-player/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-audio-player/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-player/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-player/pages/player/player.js b/TUICallKit-Vue3/native/tang-detective/package-audio-player/pages/player/player.js new file mode 100644 index 0000000..cebc842 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-player/pages/player/player.js @@ -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() + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-player/pages/player/player.json b/TUICallKit-Vue3/native/tang-detective/package-audio-player/pages/player/player.json new file mode 100644 index 0000000..ea2c8c0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-player/pages/player/player.json @@ -0,0 +1,3 @@ +{ + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-player/pages/player/player.wxml b/TUICallKit-Vue3/native/tang-detective/package-audio-player/pages/player/player.wxml new file mode 100644 index 0000000..a389e05 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-player/pages/player/player.wxml @@ -0,0 +1,73 @@ + + + + + {{chapterLabel}} · 有声夹页 + {{pageNumber}}/8 + + + + + + + 桂香里的有声夹页 + 桂香里的这一页 + + {{chapterLabel}} + {{pageLabel}} + 声音单独下载,画面仍留在原来的连环画里。 + 桂香故事 + + + + + 听这一页 + 点“开始听”后才下载并播放;来电话或离开页面会暂停,不会自己续播。 + + + + {{currentLabel}} / {{durationLabel}} + + + + + + + + + + 语速 + + + + + {{error}} + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-player/pages/player/player.wxss b/TUICallKit-Vue3/native/tang-detective/package-audio-player/pages/player/player.wxss new file mode 100644 index 0000000..0cd39c0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-player/pages/player/player.wxss @@ -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; } +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-player/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-audio-player/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-player/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-audio-player/utils/chapterRoute.js b/TUICallKit-Vue3/native/tang-detective/package-audio-player/utils/chapterRoute.js new file mode 100644 index 0000000..e5f13b3 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-audio-player/utils/chapterRoute.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS001.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS001.mp3 new file mode 100644 index 0000000..c6da13a Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS001.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS002-ATTR.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS002-ATTR.mp3 new file mode 100644 index 0000000..b98ba05 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS002-ATTR.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS002.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS002.mp3 new file mode 100644 index 0000000..28dc7dd Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS002.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS003.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS003.mp3 new file mode 100644 index 0000000..2c94b1a Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS003.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS004.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS004.mp3 new file mode 100644 index 0000000..81af735 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS004.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS005.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS005.mp3 new file mode 100644 index 0000000..482e4fd Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS005.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS006.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS006.mp3 new file mode 100644 index 0000000..420dd96 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS006.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS007.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS007.mp3 new file mode 100644 index 0000000..f5230d1 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS007.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS008.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS008.mp3 new file mode 100644 index 0000000..3070ed5 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS008.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS009.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS009.mp3 new file mode 100644 index 0000000..4dd5d00 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS009.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS010.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS010.mp3 new file mode 100644 index 0000000..1b68e6a Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS010.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS011.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS011.mp3 new file mode 100644 index 0000000..9f5c0b9 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS011.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS012.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS012.mp3 new file mode 100644 index 0000000..dd460c8 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MS012.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MT000.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MT000.mp3 new file mode 100644 index 0000000..9c37fea Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-MT000.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-TE900.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-TE900.mp3 new file mode 100644 index 0000000..2933e0b Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/audio/S01-C02-TE900.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg new file mode 100644 index 0000000..7f93697 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg new file mode 100644 index 0000000..b873e2a Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg new file mode 100644 index 0000000..73a3311 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg new file mode 100644 index 0000000..ae52ad1 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg new file mode 100644 index 0000000..c8c0250 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg new file mode 100644 index 0000000..fdb6322 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg new file mode 100644 index 0000000..0fa26aa Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg new file mode 100644 index 0000000..93a9673 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/scenes/gui-xiang-1978.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/scenes/gui-xiang-1978.jpg new file mode 100644 index 0000000..82dfa7a Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/assets/scenes/gui-xiang-1978.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/data/playableVisualPolicy.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/data/playableVisualPolicy.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/data/productionComicPages.js new file mode 100644 index 0000000..703d972 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/data/productionComicPages.js @@ -0,0 +1,2 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = {} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapter.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapter.json b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapter.wxml b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapter.wxss b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapterLayout.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapterPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/utils/comicPageModel.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/utils/comicPageModel.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-02/utils/comicReaderState.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-02/utils/comicReaderState.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS001.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS001.mp3 new file mode 100644 index 0000000..a2152f2 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS001.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS002-ATTR.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS002-ATTR.mp3 new file mode 100644 index 0000000..277461a Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS002-ATTR.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS002.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS002.mp3 new file mode 100644 index 0000000..af6b42f Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS002.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS003.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS003.mp3 new file mode 100644 index 0000000..93fad4b Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS003.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS004.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS004.mp3 new file mode 100644 index 0000000..82c8acf Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS004.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS005-ATTR.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS005-ATTR.mp3 new file mode 100644 index 0000000..5f68f27 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS005-ATTR.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS005.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS005.mp3 new file mode 100644 index 0000000..36e8647 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS005.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS006.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS006.mp3 new file mode 100644 index 0000000..670a609 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS006.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS007-ATTR.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS007-ATTR.mp3 new file mode 100644 index 0000000..393e0ab Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS007-ATTR.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS007.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS007.mp3 new file mode 100644 index 0000000..f83d7f0 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS007.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS008.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS008.mp3 new file mode 100644 index 0000000..63bbf79 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS008.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS009.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS009.mp3 new file mode 100644 index 0000000..b2d4795 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS009.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS010.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS010.mp3 new file mode 100644 index 0000000..c401310 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS010.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS011.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS011.mp3 new file mode 100644 index 0000000..e19063d Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS011.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS013.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS013.mp3 new file mode 100644 index 0000000..daac211 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS013.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS014.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS014.mp3 new file mode 100644 index 0000000..331a486 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS014.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS015.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS015.mp3 new file mode 100644 index 0000000..e394de5 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MS015.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MT000.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MT000.mp3 new file mode 100644 index 0000000..82dd5d5 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-MT000.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-TE900.mp3 b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-TE900.mp3 new file mode 100644 index 0000000..d5e4188 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/audio/S01-C03-TE900.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg new file mode 100644 index 0000000..3f9a4ea Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg new file mode 100644 index 0000000..96df34e Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg new file mode 100644 index 0000000..c237495 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg new file mode 100644 index 0000000..9b9d514 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg new file mode 100644 index 0000000..0de2c08 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg new file mode 100644 index 0000000..a448b26 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg new file mode 100644 index 0000000..15f4f1a Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg new file mode 100644 index 0000000..2046450 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/scenes/gui-xiang-1978.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/scenes/gui-xiang-1978.jpg new file mode 100644 index 0000000..82dfa7a Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/assets/scenes/gui-xiang-1978.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/data/playableVisualPolicy.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/data/playableVisualPolicy.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/data/productionComicPages.js new file mode 100644 index 0000000..703d972 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/data/productionComicPages.js @@ -0,0 +1,2 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = {} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapter.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapter.json b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapter.wxml b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapter.wxss b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapterLayout.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapterPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/utils/comicPageModel.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/utils/comicPageModel.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-03/utils/comicReaderState.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-03/utils/comicReaderState.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg new file mode 100644 index 0000000..d48d8be Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg new file mode 100644 index 0000000..b3a3fad Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg new file mode 100644 index 0000000..ee3afcd Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg new file mode 100644 index 0000000..96d0e8e Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg new file mode 100644 index 0000000..bdedfef Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg new file mode 100644 index 0000000..02c0508 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg new file mode 100644 index 0000000..e537067 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg new file mode 100644 index 0000000..196b2b0 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/scenes/gui-xiang-1978.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/scenes/gui-xiang-1978.jpg new file mode 100644 index 0000000..82dfa7a Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/assets/scenes/gui-xiang-1978.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/data/playableVisualPolicy.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/data/playableVisualPolicy.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/data/productionComicPages.js new file mode 100644 index 0000000..fc0c327 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/data/productionComicPages.js @@ -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": "" + } + ] + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapter.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapter.json b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapter.wxml b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapter.wxss b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapterLayout.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapterPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/utils/comicPageModel.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/utils/comicPageModel.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-04/utils/comicReaderState.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-04/utils/comicReaderState.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg new file mode 100644 index 0000000..34bb39a Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg new file mode 100644 index 0000000..31e85db Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg new file mode 100644 index 0000000..75bd72b Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg new file mode 100644 index 0000000..cace2e0 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg new file mode 100644 index 0000000..fa833d5 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg new file mode 100644 index 0000000..73b0444 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg new file mode 100644 index 0000000..d9a34e9 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg new file mode 100644 index 0000000..c47e907 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/scenes/gui-xiang-1978.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/scenes/gui-xiang-1978.jpg new file mode 100644 index 0000000..82dfa7a Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/assets/scenes/gui-xiang-1978.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/data/playableVisualPolicy.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/data/playableVisualPolicy.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/data/productionComicPages.js new file mode 100644 index 0000000..703d972 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/data/productionComicPages.js @@ -0,0 +1,2 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = {} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapter.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapter.json b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapter.wxml b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapter.wxss b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapterLayout.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapterPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/utils/comicPageModel.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/utils/comicPageModel.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-05/utils/comicReaderState.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-05/utils/comicReaderState.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg new file mode 100644 index 0000000..c7fe132 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg new file mode 100644 index 0000000..3657d0f Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg new file mode 100644 index 0000000..5f92c61 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg new file mode 100644 index 0000000..b5690f0 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg new file mode 100644 index 0000000..2458f00 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg new file mode 100644 index 0000000..a390ccb Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg new file mode 100644 index 0000000..e11bedc Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg new file mode 100644 index 0000000..490ca14 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/scenes/gui-xiang-1995.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/scenes/gui-xiang-1995.jpg new file mode 100644 index 0000000..98154ac Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/assets/scenes/gui-xiang-1995.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/data/playableVisualPolicy.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/data/playableVisualPolicy.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/data/productionComicPages.js new file mode 100644 index 0000000..aa0e120 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/data/productionComicPages.js @@ -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": "第一桌社会客人进门,指着靠门的第十五桌:“这张给我们拼个大桌。”" + } + ] + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapter.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapter.json b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapter.wxml b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapter.wxss b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapterLayout.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapterPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/utils/comicPageModel.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/utils/comicPageModel.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-06/utils/comicReaderState.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-06/utils/comicReaderState.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg new file mode 100644 index 0000000..ab66ed6 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg new file mode 100644 index 0000000..116dc8a Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg new file mode 100644 index 0000000..e6bb8e6 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg new file mode 100644 index 0000000..479d0d8 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg new file mode 100644 index 0000000..a5f3ecc Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg new file mode 100644 index 0000000..0d709b5 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg new file mode 100644 index 0000000..03f0fc4 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg new file mode 100644 index 0000000..69341f8 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/scenes/gui-xiang-1995.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/scenes/gui-xiang-1995.jpg new file mode 100644 index 0000000..98154ac Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/assets/scenes/gui-xiang-1995.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/data/playableVisualPolicy.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/data/playableVisualPolicy.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/data/productionComicPages.js new file mode 100644 index 0000000..9290c34 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/data/productionComicPages.js @@ -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": "三年后的电话问:“五月初八,十桌婚宴,敢不敢接?”秦师傅看向靠墙的第十五桌。" + } + ] + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapter.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapter.json b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapter.wxml b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapter.wxss b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapterLayout.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapterPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/utils/comicPageModel.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/utils/comicPageModel.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-07/utils/comicReaderState.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-07/utils/comicReaderState.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg new file mode 100644 index 0000000..8cfb542 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg new file mode 100644 index 0000000..05ddf02 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg new file mode 100644 index 0000000..1234991 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg new file mode 100644 index 0000000..41678a6 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg new file mode 100644 index 0000000..9a37902 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg new file mode 100644 index 0000000..74a2310 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg new file mode 100644 index 0000000..25db14c Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg new file mode 100644 index 0000000..af482e7 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/scenes/gui-xiang-1998.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/scenes/gui-xiang-1998.jpg new file mode 100644 index 0000000..0e21f4d Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/assets/scenes/gui-xiang-1998.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/data/playableVisualPolicy.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/data/playableVisualPolicy.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/data/productionComicPages.js new file mode 100644 index 0000000..2a6c248 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/data/productionComicPages.js @@ -0,0 +1,184 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C08": { + "pages": [ + { + "pageId": "S01-C08-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P01-title-v1.jpg", + "caption": "第一场婚宴热闹开席,第十五桌却在掌声里被推向后厨。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MT000", + "S01-C08-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P02-opening-ensemble-v1.jpg", + "caption": "主家怕桌上不够体面,秀兰翻看复写菜单;女炊事员端着饭盒,秦师傅正挪员工桌。", + "secondaryCaption": "", + "interactionPrompt": "先看看:谁在顾客桌的体面,谁正失去坐下吃饭的位置。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MS001", + "S01-C08-MS002", + "S01-C08-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P03", + "type": "event", + "eventId": "S01-H29", + "emotionMomentId": "", + "assetName": "S01-C08-P03-H29-extra-dishes-v1.jpg", + "caption": "菜还没上齐,转盘已经没有落筷子的空;主家仍怕显得小气,隔着满桌招手加菜。", + "secondaryCaption": "", + "interactionPrompt": "只因怕被说小气,再增加几道没人需要的菜,这样妥当吗?", + "shortDialogue": "主家说:“四凉八热才像样,再添两个硬菜。”林秀兰问:“先看看十个人吃到哪儿了?”", + "hotspot": { + "x": 27, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS002", + "S01-C08-MS003", + "S01-C08-MS004", + "S01-C08-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P04", + "type": "event", + "eventId": "S01-H30", + "emotionMomentId": "", + "assetName": "S01-C08-P04-H30-rejected-box-v1.jpg", + "caption": "宾客渐散,桌上还剩半桌;主家只因觉得没面子,把女炊事员递来的打包盒推回。", + "secondaryCaption": "", + "interactionPrompt": "只因觉得打包没面子,就拒绝带走适合安全保存的剩菜,这样妥当吗?", + "shortDialogue": "主家说:“喜事哪能拎剩菜走。”女炊事员答:“先问哪些能带,别让面子替食物做决定。”", + "hotspot": { + "x": 28, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P05", + "type": "event", + "eventId": "S01-H31", + "emotionMomentId": "", + "assetName": "S01-C08-P05-H31-menu-gaps-v1.jpg", + "caption": "秀兰把复写菜单翻过来,纸上只有菜名,没有人数,也没有一盘大约够几个人。", + "secondaryCaption": "", + "interactionPrompt": "套餐只列菜名,不说明大致份量和供几人,这样妥当吗?", + "shortDialogue": "林秀兰说:“光有菜名,不知道多大盘,客人怎么知道够不够?”", + "hotspot": { + "x": 22, + "y": 0, + "w": 61, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS008", + "S01-C08-MS009", + "S01-C08-MS010" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P06", + "type": "event", + "eventId": "S01-H32", + "emotionMomentId": "", + "assetName": "S01-C08-P06-H32-removed-plaque-v1.jpg", + "caption": "客桌加了,员工的座位却没了。秦师傅说:“就借这一晚。”", + "secondaryCaption": "", + "interactionPrompt": "为临时加桌,让员工整晚端碗站着吃,并说“就借这一晚”,这样妥当吗?", + "shortDialogue": "秦师傅说:“头一场席,就借一晚。”林秀兰停了一拍:“我也说过,就这一场。”", + "hotspot": { + "x": 23, + "y": 5, + "w": 44, + "h": 90 + }, + "audioCueIds": [ + "S01-C08-MS006", + "S01-C08-MS007", + "S01-C08-MS012", + "S01-C08-MS013" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM08", + "assetName": "S01-C08-P07-EM08-back-kitchen-light-v1.jpg", + "caption": "婚宴笑声还没散,女炊事员端着饭盒站在传菜口,一时找不到放碗的位置。", + "secondaryCaption": "", + "interactionPrompt": "在最忙的时候,你想怎样让她知道自己没有被忘记?", + "shortDialogue": "", + "hotspot": { + "x": 27, + "y": 0, + "w": 43, + "h": 100 + }, + "audioCueIds": [ + "S01-C08-MS006", + "S01-C08-MS007", + "S01-C08-TE900" + ], + "tableEcho": "热闹照得到客人,也该照得到端菜的人。", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg", + "caption": "热闹照得到客人,也该照得到端菜的人。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MS012", + "S01-C08-MS013", + "S01-C08-TE900", + "S01-C08-MS014" + ], + "tableEcho": "热闹照得到客人,也该照得到端菜的人。", + "cliffhanger": "现金盒合上前,铜牌背面的暗红漆与两张破损饭票一闪而过;下一场订席电话又响了。" + } + ] + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapter.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapter.json b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapter.wxml b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapter.wxss b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapterLayout.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapterPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/utils/comicPageModel.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/utils/comicPageModel.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-08/utils/comicReaderState.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-08/utils/comicReaderState.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg new file mode 100644 index 0000000..8cbc01e Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg new file mode 100644 index 0000000..699e987 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg new file mode 100644 index 0000000..aaef940 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg new file mode 100644 index 0000000..90901be Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg new file mode 100644 index 0000000..f7f0406 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg new file mode 100644 index 0000000..df1c604 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg new file mode 100644 index 0000000..b9cf975 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg new file mode 100644 index 0000000..0bbbbb5 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/scenes/gui-xiang-2001.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/scenes/gui-xiang-2001.jpg new file mode 100644 index 0000000..4db4234 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/assets/scenes/gui-xiang-2001.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/data/playableVisualPolicy.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/data/playableVisualPolicy.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/data/productionComicPages.js new file mode 100644 index 0000000..f28f998 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/data/productionComicPages.js @@ -0,0 +1,221 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C09": { + "pages": [ + { + "pageId": "S01-C09-P01", + "type": "chapter-title-and-opening-memory", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P01-opening-memory-v1.jpg", + "caption": "医院门刚合上,第一次聚餐,一盘菜从赵伯面前悄悄挪远。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "programDetailInset": { + "label": "桌边近景", + "x": 69, + "y": 51, + "w": 31, + "h": 35, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C09-MT000", + "S01-C09-MOM001", + "S01-C09-MOM002", + "S01-C09-MOM003", + "S01-C09-MTRANS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P02-opening-ensemble-v1.jpg", + "caption": "第一圈酒绕来,赵伯坐在原位看着;老周抬起手,唐守安摸向空杯,明远的酒壶停在桌中。", + "secondaryCaption": "", + "interactionPrompt": "先看看:谁在说自己的选择,谁还在替别人决定。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C09-MTRANS001", + "S01-C09-MS004", + "S01-C09-MS006", + "S01-C09-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P03", + "type": "event", + "eventId": "S01-H33", + "emotionMomentId": "", + "assetName": "S01-C09-P03-H33-driver-no-alcohol-v1.jpg", + "caption": "老周把车钥匙压在桌上,白酒仍被推来;他用整只手挡住杯子,一口也不碰。", + "secondaryCaption": "", + "interactionPrompt": "对要开车的人说“就一小口没事”,这样妥当吗?", + "shortDialogue": "老周按住钥匙:“车是我开,一口也不碰。”林秀兰把白水换到他面前。", + "hotspot": { + "x": 20, + "y": 0, + "w": 65, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS002", + "S01-C09-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P04", + "type": "event", + "eventId": "S01-H34", + "emotionMomentId": "", + "assetName": "S01-C09-P04-H34-tea-toast-v1.jpg", + "caption": "唐守安坐在靠门末席,轻盖空酒杯,端起茶;桌上的笑声一停,没人再把酒推回来。", + "secondaryCaption": "", + "interactionPrompt": "清楚拒绝饮酒,其他人也不继续起哄,这样更稳妥吗?", + "shortDialogue": "唐守安说:“心意我领,酒不喝。咱们拿茶照样碰。”", + "hotspot": { + "x": 21, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS006", + "S01-C09-MS007", + "S01-C09-MS009", + "S01-C09-MS013" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P05", + "type": "event", + "eventId": "S01-H35", + "emotionMomentId": "", + "assetName": "S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg", + "caption": "赵伯把自己的闭合随身药盒推向一旁,想为这场酒局把原来的安排往后挪。唐守安先请他停一下。", + "secondaryCaption": "涉及用药调整,应按专业人员确认的个人方案处理;不要自行停药或改量", + "outsideActionLabel": "打开赵伯这一刻", + "artTapHint": "画中赵伯也能点", + "interactionPrompt": "为了参加酒局,自行停药、补服、加倍、减量或调整胰岛素,这样妥当吗?", + "shortDialogue": "赵伯说:“今天喝酒,药先挪一挪?”唐守安摇头:“别在饭桌上自己改,先按你的方案办。”", + "hotspot": { + "x": 18, + "y": 0, + "w": 68, + "h": 99 + }, + "programDetailInset": { + "label": "手边近景", + "x": 8, + "y": 42, + "w": 50, + "h": 56, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C09-MS010", + "S01-C09-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P06", + "type": "event", + "eventId": "S01-H36", + "emotionMomentId": "", + "assetName": "S01-C09-P06-H36-stop-and-stay-v1.jpg", + "caption": "第三圈还没走完,赵伯出汗、手抖、回答变慢;唐守安先停酒、移开杯子,只留人陪在近处。", + "secondaryCaption": "", + "interactionPrompt": "发现出汗、手抖、反应变慢后,先停酒、移开危险物并留下人陪伴,这样更稳妥吗?", + "shortDialogue": "唐守安说:“先停酒,别让他一个人。能不能安全吞咽先看清,严重时马上联系急救。”", + "hotspot": { + "x": 22, + "y": 0, + "w": 62, + "h": 99 + }, + "programDetailInset": { + "label": "手边近景", + "x": 35, + "y": 42, + "w": 36, + "h": 39, + "anchor": "top-right" + }, + "audioCueIds": [ + "S01-C09-MS014", + "S01-C09-MS015", + "S01-C09-MS016" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM09", + "assetName": "S01-C09-P07-EM09-keep-my-seat-v1.jpg", + "caption": "酒杯停下后,赵伯仍坐在老位置。把座位留着,等他自己说愿不愿意坐下。", + "secondaryCaption": "", + "interactionPrompt": "你愿意怎样让赵伯先说出自己的担心?", + "shortDialogue": "", + "hotspot": { + "x": 24, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS004", + "S01-C09-MS005", + "S01-C09-MS007", + "S01-C09-MS008", + "S01-C09-MS009", + "S01-C09-TE900" + ], + "tableEcho": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg", + "caption": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C09-MS017", + "S01-C09-MS018", + "S01-C09-TE900", + "S01-C09-MS019" + ], + "tableEcho": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "memoryDisplayLines": [ + "陪他把选择说出来。", + "今天:开车就换茶或白水。", + "回家聊:先听赵伯把担心说完。" + ], + "cliffhanger": "赵伯推远酒盅,人和饭仍留在桌边;门外开始敲隔断。" + } + ] + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapter.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapter.json b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapter.wxml b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapter.wxss b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapterLayout.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapterPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/utils/comicPageModel.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/utils/comicPageModel.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-09/utils/comicReaderState.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-09/utils/comicReaderState.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg new file mode 100644 index 0000000..c1e6f73 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg new file mode 100644 index 0000000..772357a Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg new file mode 100644 index 0000000..00eab73 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg new file mode 100644 index 0000000..927cd33 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg new file mode 100644 index 0000000..9fa501d Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg new file mode 100644 index 0000000..51f7354 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg new file mode 100644 index 0000000..095a262 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg new file mode 100644 index 0000000..4329cc1 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/scenes/gui-xiang-2003.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/scenes/gui-xiang-2003.jpg new file mode 100644 index 0000000..239adda Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/assets/scenes/gui-xiang-2003.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/data/playableVisualPolicy.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/data/playableVisualPolicy.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/data/productionComicPages.js new file mode 100644 index 0000000..10ce1ff --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/data/productionComicPages.js @@ -0,0 +1,197 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C10": { + "pages": [ + { + "pageId": "S01-C10-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P01-one-palm-space-v1.jpg", + "caption": "旧桌留在后厨,女炊事员端碗回来,桌上只剩一掌空。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-MT000", + "S01-C10-MS001", + "S01-C10-MS002" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P02-opening-ensemble-v1.jpg", + "caption": "端碗的、添饭的、接电话的、端饭盒的,全站在一张不能坐的桌边。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁想坐下吃一顿完整的饭,谁又被别的事情拉走。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-MS003", + "S01-C10-MS005", + "S01-C10-MS006", + "S01-C10-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P03", + "type": "event", + "eventId": "S01-H37", + "emotionMomentId": "", + "assetName": "S01-C10-P03-H37-standing-meal-v1.jpg", + "caption": "女炊事员趁出菜空隙站着扒饭,脚边没有凳子,桌上也找不到落碗处。", + "secondaryCaption": "", + "interactionPrompt": "每天都在出菜间隙快速站着吃完,这样妥当吗?", + "shortDialogue": "女炊事员笑说:“站着吃快。”明远看着空不了的桌:“快,不等于每天都该这样。”", + "hotspot": { + "x": 21, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS001", + "S01-C10-MS002", + "S01-C10-MS003", + "S01-C10-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P04", + "type": "event", + "eventId": "S01-H38", + "emotionMomentId": "", + "assetName": "S01-C10-P04-H38-open-palm-v3.jpg", + "caption": "赵伯端着大碗、握着添饭勺;明远伸出手掌,清楚说自己已经饱了。", + "secondaryCaption": "", + "outsideActionLabel": "打开赵伯这一刻", + "artTapHint": "画中赵伯也能点", + "interactionPrompt": "用碗大、吃得多证明年轻男性有本事,这样妥当吗?", + "shortDialogue": "赵伯举起大碗:“男人吃饭,碗小了哪顶事?”明远摊开手掌:“赵叔,我已经饱了。”", + "hotspot": { + "x": 17, + "y": 0, + "w": 69, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS008", + "S01-C10-MS009", + "S01-C10-MS010", + "S01-C10-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P05", + "type": "event", + "eventId": "S01-H39", + "emotionMomentId": "", + "assetName": "S01-C10-P05-H39-phone-and-cold-meal-v1.jpg", + "caption": "长卷线座机又响,明远半坐半起去接;冷饭不再冒热气,墙钟早过了饭点。", + "secondaryCaption": "", + "interactionPrompt": "连续久坐接订席,正餐一拖再拖,这样妥当吗?", + "shortDialogue": "明远说:“接完这一个就吃。”电话那头又来一桌,他的筷子再次放下。", + "hotspot": { + "x": 18, + "y": 0, + "w": 70, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS003", + "S01-C10-MS004", + "S01-C10-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P06", + "type": "event", + "eventId": "S01-H40", + "emotionMomentId": "", + "assetName": "S01-C10-P06-H40-table-not-usable-v1.jpg", + "caption": "秦师傅说桌一直给自己人留着,可菜筐、酒箱和账本压满桌面,他的饭盒也无处放。", + "secondaryCaption": "", + "interactionPrompt": "口头说“给自己人留桌”,实际长期堆物不能坐,这样妥当吗?", + "shortDialogue": "秦师傅说:“这桌一直留着呢。”女炊事员指指酒箱:“留给谁了,酒瓶?”", + "hotspot": { + "x": 20, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS006", + "S01-C10-MS007", + "S01-C10-MS014" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM10", + "assetName": "S01-C10-P07-EM10-put-meal-down-v1.jpg", + "caption": "座机又响,明远一手拿听筒,一手端冷饭盒,桌上没有落碗处。", + "secondaryCaption": "", + "interactionPrompt": "你想怎样帮他把这一顿饭重新放回生活里?", + "shortDialogue": "", + "hotspot": { + "x": 20, + "y": 0, + "w": 66, + "h": 99 + }, + "programDetailInset": { + "label": "饭盒近景", + "x": 44, + "y": 45, + "w": 33, + "h": 37, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C10-MS005", + "S01-C10-MS012", + "S01-C10-MS013", + "S01-C10-TE900" + ], + "tableEcho": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P08-plan-line-last-stool-v1.jpg", + "caption": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-TE900", + "S01-C10-MS014" + ], + "tableEcho": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "cliffhanger": "规划红线压过桌脚,最后一张凳子被推走。字幕写着:“五年后,这张图再次展开。”" + } + ] + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapter.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapter.json b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapter.wxml b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapter.wxss b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapterLayout.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapterPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/utils/comicPageModel.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/utils/comicPageModel.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-10/utils/comicReaderState.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-10/utils/comicReaderState.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg new file mode 100644 index 0000000..a6fe073 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg new file mode 100644 index 0000000..b8a7599 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg new file mode 100644 index 0000000..26f5840 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg new file mode 100644 index 0000000..4c56708 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg new file mode 100644 index 0000000..8e9dc7f Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg new file mode 100644 index 0000000..2579796 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg new file mode 100644 index 0000000..8ba8fe0 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg new file mode 100644 index 0000000..7e3136f Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/scenes/gui-xiang-2008.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/scenes/gui-xiang-2008.jpg new file mode 100644 index 0000000..16da0e4 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/assets/scenes/gui-xiang-2008.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/data/playableVisualPolicy.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/data/playableVisualPolicy.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/data/productionComicPages.js new file mode 100644 index 0000000..c4976b7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/data/productionComicPages.js @@ -0,0 +1,184 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C11": { + "pages": [ + { + "pageId": "S01-C11-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P01-pen-above-paper-v1.jpg", + "caption": "旧图再次展开,秦师傅握着笔,先看旧桌,再看等他签字的人。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MT000", + "S01-C11-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P02-opening-ensemble-v1.jpg", + "caption": "小满擦展柜,秦师傅悬笔,施工负责人分通道;林秀兰与唐守安都在等他的决定。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在保存,谁在检查,谁在让路,谁又握着最后要落的笔。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MS002", + "S01-C11-MS003", + "S01-C11-MS004", + "S01-C11-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P03", + "type": "event", + "eventId": "S01-H41", + "emotionMomentId": "", + "assetName": "S01-C11-P03-H41-display-and-seat-v1.jpg", + "caption": "小满把玻璃与展板擦亮,回头才看见旧长凳正被搬走,林秀兰还在等她看那块空位。", + "secondaryCaption": "", + "interactionPrompt": "只把“健康、互助”做成展板,实际服务没有变化,这样妥当吗?", + "shortDialogue": "小满说:“这些字要留下。”林秀兰回她:“字留下了,人坐哪儿,也得留下。”", + "hotspot": { + "x": 20, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS002", + "S01-C11-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P04", + "type": "event", + "eventId": "S01-H42", + "emotionMomentId": "", + "assetName": "S01-C11-P04-H42-inspect-old-table-v1.jpg", + "caption": "秦师傅摸着旧桌沿想直接搬走,卷尺和检查表已递到手边,松动桌腿还没有人作结论。", + "secondaryCaption": "", + "interactionPrompt": "不检查结构安全,因怀旧就直接继续给客人使用,这样妥当吗?", + "shortDialogue": "秦师傅摸着桌沿:“它撑了几十年。”施工负责人说:“先查清还能不能安全撑今天。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS001", + "S01-C11-MS006", + "S01-C11-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P05", + "type": "event", + "eventId": "S01-H43", + "emotionMomentId": "", + "assetName": "S01-C11-P05-H43-clear-passage-v1.jpg", + "caption": "施工负责人亲手合上材料区围挡,又把通道口让开;唐守安和员工抬着长凳顺利通过。", + "secondaryCaption": "", + "interactionPrompt": "施工材料与人员必经路线分开,并保留清楚通道,这样更稳妥吗?", + "shortDialogue": "施工负责人说:“人走这一边,材料走那一边。看得见的通道,才真能走。”", + "hotspot": { + "x": 18, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS004", + "S01-C11-MS005", + "S01-C11-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P06", + "type": "event", + "eventId": "S01-H44", + "emotionMomentId": "", + "assetName": "S01-C11-P06-H44-pen-before-signature-v1.jpg", + "caption": "秦师傅把旧物清单又看一遍,笔尖终于落下半寸;林秀兰站在侧后,没有替他拿笔。", + "secondaryCaption": "", + "interactionPrompt": "白天签下旧物处置后,夜里仍保存完整桌板、铜牌与钥匙记录,这样更稳妥吗?", + "shortDialogue": "林秀兰问:“你不是说这桌不动吗?”秦师傅说:“饭店得活下来,人才能回来。”", + "hotspot": { + "x": 20, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS006", + "S01-C11-MS007", + "S01-C11-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM11", + "assetName": "S01-C11-P07-EM11-wrap-and-record-v1.jpg", + "caption": "夜里,秦师傅把桌板、铜牌和两枚破票逐件包好;空白记录还等他留下些什么。", + "secondaryCaption": "", + "interactionPrompt": "除了保存旧物,你想请秦师傅再留下些什么?", + "shortDialogue": "", + "hotspot": { + "x": 21, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS009", + "S01-C11-MS010", + "S01-C11-MS011", + "S01-C11-TE900" + ], + "tableEcho": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P08-can-we-open-now-v1.jpg", + "caption": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MS012", + "S01-C11-MS013", + "S01-C11-MS014" + ], + "tableEcho": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "cliffhanger": "十八年后,小满认出抽屉里的旧饭票夹。她没有擅自拿,只第三次问:“现在能开吗?”" + } + ] + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapter.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapter.json b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapter.wxml b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapter.wxss b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapterLayout.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapterPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/utils/comicPageModel.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/utils/comicPageModel.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-11/utils/comicReaderState.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-11/utils/comicReaderState.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg new file mode 100644 index 0000000..1c81877 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg new file mode 100644 index 0000000..a1877db Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg new file mode 100644 index 0000000..9f92712 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg new file mode 100644 index 0000000..de5cbd5 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg new file mode 100644 index 0000000..3e501cf Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg new file mode 100644 index 0000000..05ed939 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg new file mode 100644 index 0000000..50860de Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg new file mode 100644 index 0000000..ddb3d6b Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/scenes/gui-xiang-2026.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/scenes/gui-xiang-2026.jpg new file mode 100644 index 0000000..2e76e19 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/assets/scenes/gui-xiang-2026.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/data/playableVisualPolicy.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/data/playableVisualPolicy.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/data/productionComicPages.js new file mode 100644 index 0000000..a13c93e --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/data/productionComicPages.js @@ -0,0 +1,198 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C12": { + "pages": [ + { + "pageId": "S01-C12-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P01-key-pauses-half-turn-v1.jpg", + "caption": "爷爷点了头,小满才取下饭票夹;钥匙转到一半,她又停下来说明。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MT000", + "S01-C12-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P02-modern-table-ensemble-v1.jpg", + "caption": "旧桌板被人擦亮,纸单被人铺开;赵伯按加号,明远拿起信息卡,秦师傅仍站在后厨门里。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在核对旧物,谁把点餐方式摆出来,谁又想替一家人一次安排完。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MS002", + "S01-C12-MS003", + "S01-C12-MS005", + "S01-C12-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P03", + "type": "event", + "eventId": "S01-H45", + "emotionMomentId": "", + "assetName": "S01-C12-P03-H45-provenance-chain-v1.jpg", + "caption": "小满先拍桌板全貌,再拍红漆、孔位和票据;林秀兰擦板,秦师傅把纸筒递到手边。", + "secondaryCaption": "", + "interactionPrompt": "征得秦师傅同意后开柜,并用红漆、孔位、板字、照片和票据共同核对,这样更稳妥吗?", + "shortDialogue": "小满说:“这回能开吗?”秦师傅点头。她又说:“一件一件记,别让一个孔替所有证据说话。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS001", + "S01-C12-MS002", + "S01-C12-MS003", + "S01-C12-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P04", + "type": "event", + "eventId": "S01-H46", + "emotionMomentId": "", + "assetName": "S01-C12-P04-H46-real-choice-counter-v1.jpg", + "caption": "小满把大字纸单推向老人,员工当面等他开口;扫码牌在旁,现金盒也正常打开。", + "secondaryCaption": "", + "interactionPrompt": "在扫码之外保留大字纸单、人工点餐,并保持正常现金收款,这样更稳妥吗?", + "shortDialogue": "小满说:“会扫码就扫,想看纸单就看纸单;有人在这儿帮,现金也照常收。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS005", + "S01-C12-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P05", + "type": "event", + "eventId": "S01-H47", + "emotionMomentId": "", + "assetName": "S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg", + "caption": "十个人围着真实饭桌,赵伯熟练按下加号;乐乐只按住自己的手,先问每份多大。", + "secondaryCaption": "", + "interactionPrompt": "因“七十周年不能空”继续加菜,这样妥当吗?", + "shortDialogue": "赵伯说:“十四不好听,再来俩。”乐乐按住减号:“十个人十四道,已经不是数学问题了。”", + "hotspot": { + "x": 15, + "y": 0, + "w": 69, + "h": 99 + }, + "programEvidenceInset": { + "kind": "order-count", + "kicker": "画中近景", + "screenLabel": "手机点单", + "countLabel": "已点", + "countValue": 14, + "countUnit": "道", + "plusGlyph": "+", + "note": "赵伯还在加菜", + "anchor": "top-right", + "ariaLabel": "画中近景:手机显示已点十四道,赵伯还要按加号继续加菜。" + }, + "audioCueIds": [ + "S01-C12-MS007", + "S01-C12-MS008", + "S01-C12-MS009" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P06", + "type": "event", + "eventId": "S01-H48", + "emotionMomentId": "", + "assetName": "S01-C12-P06-H48-card-is-not-a-promise-v1.jpg", + "caption": "明远把信息卡往父亲面前一放,像找到了省心答案;乐乐却指着空白栏,等他把话听完。", + "secondaryCaption": "", + "interactionPrompt": "看到“烹调方式:炖;本份约供2—3人;可选小份”,就理解成“健康保证”,这样妥当吗?", + "shortDialogue": "明远说:“写得这么健康,多点一份没事。”乐乐问:“它说怎么做、多大份,又没说能治病吧?”", + "hotspot": { + "x": 18, + "y": 0, + "w": 67, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS010", + "S01-C12-MS011", + "S01-C12-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM12", + "assetName": "S01-C12-P07-EM12-hand-choice-back-v1.jpg", + "caption": "手机、语音、纸单和服务员都在;乐乐把纸单放到赵伯手边,明远终于停下来等他开口。", + "secondaryCaption": "", + "interactionPrompt": "信息都在了,最后一步怎样交还给赵伯?", + "shortDialogue": "", + "hotspot": { + "x": 18, + "y": 0, + "w": 67, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS005", + "S01-C12-MS006", + "S01-C12-TE900" + ], + "tableEcho": "让人看懂、让人开口,才算把选择递到手里。", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P08-rolled-speech-cliffhanger-v1.jpg", + "caption": "让人看懂、让人开口,才算把选择递到手里。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MS013", + "S01-C12-MS014-ATTR", + "S01-C12-MS014", + "S01-C12-MS015", + "S01-C12-MS016" + ], + "tableEcho": "让人看懂、让人开口,才算把选择递到手里。", + "cliffhanger": "秦师傅把稿子重新卷起,只说:“先上菜。”林秀兰看见末行写着“请晚班的人原谅我”。" + } + ] + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapter.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapter.json b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapter.wxml b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapter.wxss b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapterLayout.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapterPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/utils/comicPageModel.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/utils/comicPageModel.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-12/utils/comicReaderState.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-12/utils/comicReaderState.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg new file mode 100644 index 0000000..aac875e Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg new file mode 100644 index 0000000..870d392 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg new file mode 100644 index 0000000..d5b4c52 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg new file mode 100644 index 0000000..748b0ff Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg new file mode 100644 index 0000000..ac2fb9f Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg new file mode 100644 index 0000000..b95cb1b Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg new file mode 100644 index 0000000..f517bdd Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg new file mode 100644 index 0000000..7657eb4 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/scenes/gui-xiang-2026.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/scenes/gui-xiang-2026.jpg new file mode 100644 index 0000000..2e76e19 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/assets/scenes/gui-xiang-2026.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/data/playableVisualPolicy.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/data/playableVisualPolicy.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/data/productionComicPages.js new file mode 100644 index 0000000..27b0385 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/data/productionComicPages.js @@ -0,0 +1,196 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C13": { + "pages": [ + { + "pageId": "S01-C13-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P01-three-chopsticks-collide-v1.jpg", + "caption": "乐乐刚说饱,三双公筷却同时伸来;只听“叮”的一声,桌边静了半拍。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MT000", + "S01-C13-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P02-opening-ensemble-v1.jpg", + "caption": "乐乐护碗,明远推盘,赵伯的姓名牌去了侧桌;小满手里的软尺,还没有展开。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁还在替人夹,谁把自己的菜推过去,谁又让姓名牌先离了席。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MS002", + "S01-C13-MS003", + "S01-C13-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P03", + "type": "event", + "eventId": "S01-H49", + "emotionMomentId": "", + "assetName": "S01-C13-P03-H49-ask-before-serving-v1.jpg", + "caption": "三位长辈都想照顾孩子,却没有一个先问;公筷干净,也不能替乐乐说“要”。", + "secondaryCaption": "", + "interactionPrompt": "不问乐乐就轮流替他夹菜,这样妥当吗?", + "shortDialogue": "赵伯笑:“还排上号了。”乐乐护住碗:“公筷也是筷子,先问我呀!”", + "hotspot": { + "x": 14, + "y": 3, + "w": 70, + "h": 94 + }, + "audioCueIds": [ + "S01-C13-MS001", + "S01-C13-MS002", + "S01-C13-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P04", + "type": "event", + "eventId": "S01-H50", + "emotionMomentId": "", + "assetName": "S01-C13-P04-H50-name-card-leaves-first-v1.jpg", + "caption": "小满想把照顾安排周全,却先把姓名牌移去侧桌;赵伯本人仍坐在大家中间。", + "secondaryCaption": "", + "interactionPrompt": "因担心赵伯,未经商量就把他安排到隔开的桌上,这样妥当吗?", + "shortDialogue": "赵伯探身说:“我人还在这儿,牌先被请走了?”小满的手停在半空。", + "hotspot": { + "x": 43, + "y": 1, + "w": 54, + "h": 98 + }, + "programDetailInset": { + "label": "桌边近景", + "x": 65, + "y": 36, + "w": 35, + "h": 39, + "anchor": "top-right", + "labelAnchor": "bottom-left" + }, + "audioCueIds": [ + "S01-C13-MS006", + "S01-C13-MS007", + "S01-C13-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P05", + "type": "event", + "eventId": "S01-H51", + "emotionMomentId": "", + "assetName": "S01-C13-P05-H51-family-double-standard-v1.jpg", + "caption": "明远把自己的甜饮举到嘴边,另一只手还搭在推给乐乐的菜盘上;乐乐抬眼看他。", + "secondaryCaption": "", + "interactionPrompt": "大人提醒孩子少喝,自己却仍举着甜饮,这样妥当吗?", + "shortDialogue": "明远说:“饮料少喝。”乐乐看着他的瓶子:“爸爸,那咱们一起少喝,好吗?”", + "hotspot": { + "x": 18, + "y": 0, + "w": 68, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS004", + "S01-C13-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P06", + "type": "event", + "eventId": "S01-H52", + "emotionMomentId": "", + "assetName": "S01-C13-P06-H52-tape-stays-rolled-v1.jpg", + "caption": "小满看着姓名牌和卷起的软尺,没有先量侧桌;这一回,她决定先问坐桌的人。", + "secondaryCaption": "", + "interactionPrompt": "不先挪人,先问赵伯想坐哪里,再调整共同桌,这样更稳妥吗?", + "shortDialogue": "小满问:“赵伯,您想坐哪儿?”赵伯把椅子往里收了收:“我还坐大家旁边。”", + "hotspot": { + "x": 24, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS009", + "S01-C13-MS010", + "S01-C13-MS011", + "S01-C13-MS012", + "S01-C13-MS013", + "S01-C13-MS014", + "S01-C13-MS015" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM13", + "assetName": "S01-C13-P07-EM13-listen-to-child-v1.jpg", + "caption": "三双公筷终于都停下。乐乐还护着碗,等大人第一次不替他说话。", + "secondaryCaption": "", + "interactionPrompt": "这一回,家里人怎样让乐乐自己选?", + "shortDialogue": "", + "hotspot": { + "x": 24, + "y": 0, + "w": 54, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS010", + "S01-C13-MS011", + "S01-C13-MS012", + "S01-C13-TE900" + ], + "tableEcho": "这一次,大人终于听孩子把话说完。", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P08", + "type": "memory-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P08-cook-crosses-threshold-v1.jpg", + "caption": "为你好之前,先听一句“我要不要”;照顾也要把选择还给人。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MS016", + "S01-C13-MS017", + "S01-C13-MS018" + ], + "tableEcho": "", + "cliffhanger": "秦师傅端起一锅白菜热汤面,终于跨过躲了整晚的后厨门槛。" + } + ] + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapter.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapter.json b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapter.wxml b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapter.wxss b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapterLayout.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapterPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/utils/comicPageModel.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/utils/comicPageModel.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-13/utils/comicReaderState.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-13/utils/comicReaderState.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg new file mode 100644 index 0000000..a6e02c0 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg new file mode 100644 index 0000000..a23a19f Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg new file mode 100644 index 0000000..fdaedbd Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg new file mode 100644 index 0000000..558c6da Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg new file mode 100644 index 0000000..9e03533 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg new file mode 100644 index 0000000..7cdb542 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg new file mode 100644 index 0000000..176990c Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg new file mode 100644 index 0000000..586bf83 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/scenes/gui-xiang-2026.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/scenes/gui-xiang-2026.jpg new file mode 100644 index 0000000..2e76e19 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/assets/scenes/gui-xiang-2026.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/data/playableVisualPolicy.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/data/playableVisualPolicy.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/data/productionComicPages.js new file mode 100644 index 0000000..2b4dbe8 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/data/productionComicPages.js @@ -0,0 +1,187 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C14": { + "pages": [ + { + "pageId": "S01-C14-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P01-lid-opens-old-scent-v1.jpg", + "caption": "锅盖一揭,白菜热汤面冒起白汽;赵伯一句“就这”,忽然停在了半截。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MT000", + "S01-C14-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P02-responsibility-ensemble-v1.jpg", + "caption": "秦师傅扶住旧板,林秀兰排开证据;唐守安停在门框,赵伯从普通座端茶起身。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在说自己的责任,谁只补证据,谁没有往主位走,谁仍在桌上举杯。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MS002", + "S01-C14-MS003", + "S01-C14-MS004", + "S01-C14-MS005", + "S01-C14-MS006", + "S01-C14-MS007-ATTR", + "S01-C14-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P03", + "type": "event", + "eventId": "S01-H53", + "emotionMomentId": "", + "assetName": "S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg", + "caption": "秦师傅只讲白菜、面、热汤、做法与份量;旧味能留人情,不能替代治疗。", + "secondaryCaption": "", + "interactionPrompt": "因为是老菜,就宣传成“祖传降糖面”,这样妥当吗?", + "shortDialogue": "秦师傅说:“就是白菜、面和一锅热汤。讲做法、讲份量,别给它封神。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 68, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS001", + "S01-C14-MS002", + "S01-C14-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P04", + "type": "event", + "eventId": "S01-H54", + "emotionMomentId": "", + "assetName": "S01-C14-P04-H54-evidence-chain-v1.jpg", + "caption": "林秀兰把两枚破票、铜牌、红漆、孔位、板字与照片逐项核对;没有一件物证独自定案。", + "secondaryCaption": "", + "interactionPrompt": "把票据、铜牌、暗红漆、孔位、板字和照片一起核对,这样更稳妥吗?", + "shortDialogue": "林秀兰说:“每件东西只说自己知道的那一段,合起来,故事才站得稳。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 71, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS004", + "S01-C14-MS007-ATTR", + "S01-C14-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P05", + "type": "event", + "eventId": "S01-H55", + "emotionMomentId": "", + "assetName": "S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg", + "caption": "唐守安停在门框,没有去主位;赵伯拉开身边的普通座,秦师傅的话仍由秦师傅说完。", + "secondaryCaption": "", + "interactionPrompt": "大家请他坐主位,他选择普通座并让秦师傅自己说完,这样更稳妥吗?", + "shortDialogue": "赵伯喊:“给唐大夫留个座。”唐守安摆手:“普通座就好,先听老秦把话说完。”", + "hotspot": { + "x": 48, + "y": 1, + "w": 48, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS008", + "S01-C14-MS009", + "S01-C14-MS010" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P06", + "type": "event", + "eventId": "S01-H56", + "emotionMomentId": "", + "assetName": "S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg", + "caption": "赵伯把蓝花盖碗转半圈,主动同大家碰杯;不用酒证明能耐,他也没有离开这张桌。", + "secondaryCaption": "", + "interactionPrompt": "用茶参加碰杯,也不再要求别人喝酒,这样更稳妥吗?", + "shortDialogue": "赵伯说:“这回我当个不劝酒、不硬撑的骨干。老伙计,茶也算一杯。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 65, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS011", + "S01-C14-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM14", + "assetName": "S01-C14-P07-EM14-watch-face-down-v1.jpg", + "caption": "父子同高坐下,那只曾让问话匆匆结束的手表,还亮在两人之间。", + "secondaryCaption": "", + "interactionPrompt": "唐守安怎样让儿子把那句旧话真正说完?", + "shortDialogue": "", + "hotspot": { + "x": 8, + "y": 1, + "w": 84, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS012", + "S01-C14-MS013", + "S01-C14-MS014", + "S01-C14-MS015", + "S01-C14-TE900" + ], + "tableEcho": "会解决问题的人,也可以学着先听一会儿。", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P08", + "type": "memory-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P08-measure-the-empty-place-v1.jpg", + "caption": "会解决问题,也要先听一会儿;桌子能否回来,先看那块空位最终坐回谁。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MS016", + "S01-C14-MS017" + ], + "tableEcho": "", + "cliffhanger": "秦师傅问:“十五桌还能回来吗?”小满没有回答,先拿软尺重新量空位。" + } + ] + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapter.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapter.json b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapter.wxml b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapter.wxss b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapterLayout.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapterPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/utils/comicPageModel.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/utils/comicPageModel.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-14/utils/comicReaderState.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-14/utils/comicReaderState.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg new file mode 100644 index 0000000..69e70da Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg new file mode 100644 index 0000000..fc5eb28 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg new file mode 100644 index 0000000..edd67fd Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg new file mode 100644 index 0000000..24665f8 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg new file mode 100644 index 0000000..0953f20 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg new file mode 100644 index 0000000..e88abf7 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg new file mode 100644 index 0000000..587d7e5 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg new file mode 100644 index 0000000..58af4c9 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/scenes/gui-xiang-2026.jpg b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/scenes/gui-xiang-2026.jpg new file mode 100644 index 0000000..2e76e19 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/assets/scenes/gui-xiang-2026.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/data/playableVisualPolicy.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/data/playableVisualPolicy.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/data/productionComicPages.js new file mode 100644 index 0000000..a0ce914 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/data/productionComicPages.js @@ -0,0 +1,204 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C15": { + "pages": [ + { + "pageId": "S01-C15-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P01-chair-opens-for-late-workers-v1.jpg", + "caption": "三周后,晚市将收;小满先拉开椅子,门口那几个人终于不用再等。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C15-MT000", + "S01-C15-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P02-everyone-makes-room-ensemble-v1.jpg", + "caption": "小满把椅子朝共同桌拉开,赵伯抬手招呼;两位晚班工友走进门,来晚的人也有位置。", + "secondaryCaption": "", + "interactionPrompt": "看看小满把椅子拉向哪里,再看门口的人正往哪里走。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C15-MS002", + "S01-C15-MS003", + "S01-C15-MS004", + "S01-C15-MS005", + "S01-C15-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P03", + "type": "event", + "eventId": "S01-H57", + "emotionMomentId": "", + "assetName": "S01-C15-P03-H57-three-real-portions-v1.jpg", + "caption": "小甄先看来了几个人,再把普通份、小份、半份三种真实餐盘推到大家眼前。", + "secondaryCaption": "", + "interactionPrompt": "菜单提供多种份量并写建议用餐人数,这样更稳妥吗?", + "shortDialogue": "小甄说:“先看几个人、每份多大。少点不够再添,比猜一桌需要多少更踏实。”", + "hotspot": { + "x": 17, + "y": 1, + "w": 55, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS003", + "S01-C15-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P04", + "type": "event", + "eventId": "S01-H58", + "emotionMomentId": "", + "assetName": "S01-C15-P04-H58-water-within-reach-v1.jpg", + "caption": "小甄把白水壶放到共同桌边;其他饮品仍在侧柜,先问本人,再决定要不要。", + "secondaryCaption": "", + "interactionPrompt": "默认让白水容易取得,酒和甜饮由本人选择,不替所有人自动摆上,这样更稳妥吗?", + "shortDialogue": "小甄说:“白水先放手边,其他饮品看清信息、问过本人,再决定要不要。”", + "hotspot": { + "x": 41, + "y": 1, + "w": 49, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS003", + "S01-C15-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P05", + "type": "event", + "eventId": "S01-H59", + "emotionMomentId": "", + "assetName": "S01-C15-P05-H59-ask-before-serving-v1.jpg", + "caption": "明远的公筷停在乐乐碗外。他先看孩子的脸,再问:“这个还要吗?”", + "secondaryCaption": "", + "interactionPrompt": "夹菜前先问乐乐还要不要,这样更稳妥吗?", + "shortDialogue": "明远问:“这个还要吗?”乐乐点头:“要一点,我自己夹。”明远把公筷递过去。", + "hotspot": { + "x": 12, + "y": 1, + "w": 58, + "h": 98 + }, + "programDetailInset": { + "label": "公筷停住", + "x": 22, + "y": 38, + "w": 51, + "h": 57, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C15-MS007", + "S01-C15-MS008", + "S01-C15-MS009", + "S01-C15-MS010", + "S01-C15-MS011", + "S01-C15-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P06", + "type": "event", + "eventId": "S01-H60", + "emotionMomentId": "", + "assetName": "S01-C15-P06-H60-check-before-packing-v1.jpg", + "caption": "员工没有把剩菜一股脑装盒;她逐盘询问,也把保存和再加热提示一项项说明。", + "secondaryCaption": "", + "interactionPrompt": "所有剩菜不分情况都必须打包,这样妥当吗?", + "shortDialogue": "员工问:“这些要带走吗?我先把能不能保存、怎么再加热给您说清。”", + "hotspot": { + "x": 31, + "y": 1, + "w": 58, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS016" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM15", + "assetName": "S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg", + "caption": "同一机位里,人已坐满;老吕带来的右缺口蓝边旧碗,还是空的。", + "secondaryCaption": "", + "interactionPrompt": "这只旧碗怎样留在今天的第十五桌边?", + "shortDialogue": "", + "hotspot": { + "x": 12, + "y": 1, + "w": 76, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS013", + "S01-C15-MS014", + "S01-C15-MS015", + "S01-C15-MS016", + "S01-C15-TE900", + "S01-C15-MS017" + ], + "tableEcho": "桌子回来了,人也都坐回来了。", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P08", + "type": "memory-season-close", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P08-people-seated-season-close-v2.jpg", + "caption": "第一回少的桌,如今有人坐回来了。铜牌回到桌沿,晚班工友也坐回来了。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "programDetailInset": { + "label": "秦师傅", + "x": 72, + "y": 7, + "w": 25, + "h": 44, + "anchor": "top-right" + }, + "audioCueIds": [ + "S01-C15-MS016", + "S01-C15-TE900", + "S01-C15-MS017", + "S01-C15-MS018" + ], + "tableEcho": "", + "cliffhanger": "快门按下,旧铜牌“15”重新固定。原来缺的不是一张桌,是这些人应该有的位置。" + } + ] + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapter.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapter.json b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapter.wxml b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapter.wxss b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapterLayout.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapterPages.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/utils/comicPageModel.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/utils/comicPageModel.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-chapter-15/utils/comicReaderState.js b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-chapter-15/utils/comicReaderState.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/assets/audio/S01-C01-MS007.mp3 b/TUICallKit-Vue3/native/tang-detective/package-game/assets/audio/S01-C01-MS007.mp3 new file mode 100644 index 0000000..5fbb9a5 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-game/assets/audio/S01-C01-MS007.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/assets/audio/S01-C01-MS010.mp3 b/TUICallKit-Vue3/native/tang-detective/package-game/assets/audio/S01-C01-MS010.mp3 new file mode 100644 index 0000000..14692a7 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-game/assets/audio/S01-C01-MS010.mp3 differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg new file mode 100644 index 0000000..30a4e90 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg new file mode 100644 index 0000000..99e297d Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg new file mode 100644 index 0000000..872fe84 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg new file mode 100644 index 0000000..dcb42b9 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg new file mode 100644 index 0000000..7081936 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg new file mode 100644 index 0000000..ce5849e Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg b/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg new file mode 100644 index 0000000..836160b Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/assets/scenes/gui-xiang-2026.jpg b/TUICallKit-Vue3/native/tang-detective/package-game/assets/scenes/gui-xiang-2026.jpg new file mode 100644 index 0000000..2e76e19 Binary files /dev/null and b/TUICallKit-Vue3/native/tang-detective/package-game/assets/scenes/gui-xiang-2026.jpg differ diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/data/assetReleaseConfig.js b/TUICallKit-Vue3/native/tang-detective/package-game/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-game/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/data/playableVisualPolicy.js b/TUICallKit-Vue3/native/tang-detective/package-game/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-game/data/playableVisualPolicy.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/data/productionComicPages.js b/TUICallKit-Vue3/native/tang-detective/package-game/data/productionComicPages.js new file mode 100644 index 0000000..c191ba8 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-game/data/productionComicPages.js @@ -0,0 +1,2067 @@ +// Generated from the reviewed C04 and C06-C15 production storyboards. 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": "" + } + ] + }, + "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": "第一桌社会客人进门,指着靠门的第十五桌:“这张给我们拼个大桌。”" + } + ] + }, + "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": "三年后的电话问:“五月初八,十桌婚宴,敢不敢接?”秦师傅看向靠墙的第十五桌。" + } + ] + }, + "S01-C08": { + "pages": [ + { + "pageId": "S01-C08-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P01-title-v1.jpg", + "caption": "第一场婚宴热闹开席,第十五桌却在掌声里被推向后厨。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MT000", + "S01-C08-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P02-opening-ensemble-v1.jpg", + "caption": "主家怕桌上不够体面,秀兰翻看复写菜单;女炊事员端着饭盒,秦师傅正挪员工桌。", + "secondaryCaption": "", + "interactionPrompt": "先看看:谁在顾客桌的体面,谁正失去坐下吃饭的位置。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MS001", + "S01-C08-MS002", + "S01-C08-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P03", + "type": "event", + "eventId": "S01-H29", + "emotionMomentId": "", + "assetName": "S01-C08-P03-H29-extra-dishes-v1.jpg", + "caption": "菜还没上齐,转盘已经没有落筷子的空;主家仍怕显得小气,隔着满桌招手加菜。", + "secondaryCaption": "", + "interactionPrompt": "只因怕被说小气,再增加几道没人需要的菜,这样妥当吗?", + "shortDialogue": "主家说:“四凉八热才像样,再添两个硬菜。”林秀兰问:“先看看十个人吃到哪儿了?”", + "hotspot": { + "x": 27, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS002", + "S01-C08-MS003", + "S01-C08-MS004", + "S01-C08-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P04", + "type": "event", + "eventId": "S01-H30", + "emotionMomentId": "", + "assetName": "S01-C08-P04-H30-rejected-box-v1.jpg", + "caption": "宾客渐散,桌上还剩半桌;主家只因觉得没面子,把女炊事员递来的打包盒推回。", + "secondaryCaption": "", + "interactionPrompt": "只因觉得打包没面子,就拒绝带走适合安全保存的剩菜,这样妥当吗?", + "shortDialogue": "主家说:“喜事哪能拎剩菜走。”女炊事员答:“先问哪些能带,别让面子替食物做决定。”", + "hotspot": { + "x": 28, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P05", + "type": "event", + "eventId": "S01-H31", + "emotionMomentId": "", + "assetName": "S01-C08-P05-H31-menu-gaps-v1.jpg", + "caption": "秀兰把复写菜单翻过来,纸上只有菜名,没有人数,也没有一盘大约够几个人。", + "secondaryCaption": "", + "interactionPrompt": "套餐只列菜名,不说明大致份量和供几人,这样妥当吗?", + "shortDialogue": "林秀兰说:“光有菜名,不知道多大盘,客人怎么知道够不够?”", + "hotspot": { + "x": 22, + "y": 0, + "w": 61, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS008", + "S01-C08-MS009", + "S01-C08-MS010" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P06", + "type": "event", + "eventId": "S01-H32", + "emotionMomentId": "", + "assetName": "S01-C08-P06-H32-removed-plaque-v1.jpg", + "caption": "客桌加了,员工的座位却没了。秦师傅说:“就借这一晚。”", + "secondaryCaption": "", + "interactionPrompt": "为临时加桌,让员工整晚端碗站着吃,并说“就借这一晚”,这样妥当吗?", + "shortDialogue": "秦师傅说:“头一场席,就借一晚。”林秀兰停了一拍:“我也说过,就这一场。”", + "hotspot": { + "x": 23, + "y": 5, + "w": 44, + "h": 90 + }, + "audioCueIds": [ + "S01-C08-MS006", + "S01-C08-MS007", + "S01-C08-MS012", + "S01-C08-MS013" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM08", + "assetName": "S01-C08-P07-EM08-back-kitchen-light-v1.jpg", + "caption": "婚宴笑声还没散,女炊事员端着饭盒站在传菜口,一时找不到放碗的位置。", + "secondaryCaption": "", + "interactionPrompt": "在最忙的时候,你想怎样让她知道自己没有被忘记?", + "shortDialogue": "", + "hotspot": { + "x": 27, + "y": 0, + "w": 43, + "h": 100 + }, + "audioCueIds": [ + "S01-C08-MS006", + "S01-C08-MS007", + "S01-C08-TE900" + ], + "tableEcho": "热闹照得到客人,也该照得到端菜的人。", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg", + "caption": "热闹照得到客人,也该照得到端菜的人。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MS012", + "S01-C08-MS013", + "S01-C08-TE900", + "S01-C08-MS014" + ], + "tableEcho": "热闹照得到客人,也该照得到端菜的人。", + "cliffhanger": "现金盒合上前,铜牌背面的暗红漆与两张破损饭票一闪而过;下一场订席电话又响了。" + } + ] + }, + "S01-C09": { + "pages": [ + { + "pageId": "S01-C09-P01", + "type": "chapter-title-and-opening-memory", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P01-opening-memory-v1.jpg", + "caption": "医院门刚合上,第一次聚餐,一盘菜从赵伯面前悄悄挪远。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "programDetailInset": { + "label": "桌边近景", + "x": 69, + "y": 51, + "w": 31, + "h": 35, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C09-MT000", + "S01-C09-MOM001", + "S01-C09-MOM002", + "S01-C09-MOM003", + "S01-C09-MTRANS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P02-opening-ensemble-v1.jpg", + "caption": "第一圈酒绕来,赵伯坐在原位看着;老周抬起手,唐守安摸向空杯,明远的酒壶停在桌中。", + "secondaryCaption": "", + "interactionPrompt": "先看看:谁在说自己的选择,谁还在替别人决定。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C09-MTRANS001", + "S01-C09-MS004", + "S01-C09-MS006", + "S01-C09-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P03", + "type": "event", + "eventId": "S01-H33", + "emotionMomentId": "", + "assetName": "S01-C09-P03-H33-driver-no-alcohol-v1.jpg", + "caption": "老周把车钥匙压在桌上,白酒仍被推来;他用整只手挡住杯子,一口也不碰。", + "secondaryCaption": "", + "interactionPrompt": "对要开车的人说“就一小口没事”,这样妥当吗?", + "shortDialogue": "老周按住钥匙:“车是我开,一口也不碰。”林秀兰把白水换到他面前。", + "hotspot": { + "x": 20, + "y": 0, + "w": 65, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS002", + "S01-C09-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P04", + "type": "event", + "eventId": "S01-H34", + "emotionMomentId": "", + "assetName": "S01-C09-P04-H34-tea-toast-v1.jpg", + "caption": "唐守安坐在靠门末席,轻盖空酒杯,端起茶;桌上的笑声一停,没人再把酒推回来。", + "secondaryCaption": "", + "interactionPrompt": "清楚拒绝饮酒,其他人也不继续起哄,这样更稳妥吗?", + "shortDialogue": "唐守安说:“心意我领,酒不喝。咱们拿茶照样碰。”", + "hotspot": { + "x": 21, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS006", + "S01-C09-MS007", + "S01-C09-MS009", + "S01-C09-MS013" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P05", + "type": "event", + "eventId": "S01-H35", + "emotionMomentId": "", + "assetName": "S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg", + "caption": "赵伯把自己的闭合随身药盒推向一旁,想为这场酒局把原来的安排往后挪。唐守安先请他停一下。", + "secondaryCaption": "涉及用药调整,应按专业人员确认的个人方案处理;不要自行停药或改量", + "outsideActionLabel": "打开赵伯这一刻", + "artTapHint": "画中赵伯也能点", + "interactionPrompt": "为了参加酒局,自行停药、补服、加倍、减量或调整胰岛素,这样妥当吗?", + "shortDialogue": "赵伯说:“今天喝酒,药先挪一挪?”唐守安摇头:“别在饭桌上自己改,先按你的方案办。”", + "hotspot": { + "x": 18, + "y": 0, + "w": 68, + "h": 99 + }, + "programDetailInset": { + "label": "手边近景", + "x": 8, + "y": 42, + "w": 50, + "h": 56, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C09-MS010", + "S01-C09-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P06", + "type": "event", + "eventId": "S01-H36", + "emotionMomentId": "", + "assetName": "S01-C09-P06-H36-stop-and-stay-v1.jpg", + "caption": "第三圈还没走完,赵伯出汗、手抖、回答变慢;唐守安先停酒、移开杯子,只留人陪在近处。", + "secondaryCaption": "", + "interactionPrompt": "发现出汗、手抖、反应变慢后,先停酒、移开危险物并留下人陪伴,这样更稳妥吗?", + "shortDialogue": "唐守安说:“先停酒,别让他一个人。能不能安全吞咽先看清,严重时马上联系急救。”", + "hotspot": { + "x": 22, + "y": 0, + "w": 62, + "h": 99 + }, + "programDetailInset": { + "label": "手边近景", + "x": 35, + "y": 42, + "w": 36, + "h": 39, + "anchor": "top-right" + }, + "audioCueIds": [ + "S01-C09-MS014", + "S01-C09-MS015", + "S01-C09-MS016" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM09", + "assetName": "S01-C09-P07-EM09-keep-my-seat-v1.jpg", + "caption": "酒杯停下后,赵伯仍坐在老位置。把座位留着,等他自己说愿不愿意坐下。", + "secondaryCaption": "", + "interactionPrompt": "你愿意怎样让赵伯先说出自己的担心?", + "shortDialogue": "", + "hotspot": { + "x": 24, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS004", + "S01-C09-MS005", + "S01-C09-MS007", + "S01-C09-MS008", + "S01-C09-MS009", + "S01-C09-TE900" + ], + "tableEcho": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg", + "caption": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C09-MS017", + "S01-C09-MS018", + "S01-C09-TE900", + "S01-C09-MS019" + ], + "tableEcho": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "memoryDisplayLines": [ + "陪他把选择说出来。", + "今天:开车就换茶或白水。", + "回家聊:先听赵伯把担心说完。" + ], + "cliffhanger": "赵伯推远酒盅,人和饭仍留在桌边;门外开始敲隔断。" + } + ] + }, + "S01-C10": { + "pages": [ + { + "pageId": "S01-C10-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P01-one-palm-space-v1.jpg", + "caption": "旧桌留在后厨,女炊事员端碗回来,桌上只剩一掌空。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-MT000", + "S01-C10-MS001", + "S01-C10-MS002" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P02-opening-ensemble-v1.jpg", + "caption": "端碗的、添饭的、接电话的、端饭盒的,全站在一张不能坐的桌边。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁想坐下吃一顿完整的饭,谁又被别的事情拉走。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-MS003", + "S01-C10-MS005", + "S01-C10-MS006", + "S01-C10-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P03", + "type": "event", + "eventId": "S01-H37", + "emotionMomentId": "", + "assetName": "S01-C10-P03-H37-standing-meal-v1.jpg", + "caption": "女炊事员趁出菜空隙站着扒饭,脚边没有凳子,桌上也找不到落碗处。", + "secondaryCaption": "", + "interactionPrompt": "每天都在出菜间隙快速站着吃完,这样妥当吗?", + "shortDialogue": "女炊事员笑说:“站着吃快。”明远看着空不了的桌:“快,不等于每天都该这样。”", + "hotspot": { + "x": 21, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS001", + "S01-C10-MS002", + "S01-C10-MS003", + "S01-C10-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P04", + "type": "event", + "eventId": "S01-H38", + "emotionMomentId": "", + "assetName": "S01-C10-P04-H38-open-palm-v3.jpg", + "caption": "赵伯端着大碗、握着添饭勺;明远伸出手掌,清楚说自己已经饱了。", + "secondaryCaption": "", + "outsideActionLabel": "打开赵伯这一刻", + "artTapHint": "画中赵伯也能点", + "interactionPrompt": "用碗大、吃得多证明年轻男性有本事,这样妥当吗?", + "shortDialogue": "赵伯举起大碗:“男人吃饭,碗小了哪顶事?”明远摊开手掌:“赵叔,我已经饱了。”", + "hotspot": { + "x": 17, + "y": 0, + "w": 69, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS008", + "S01-C10-MS009", + "S01-C10-MS010", + "S01-C10-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P05", + "type": "event", + "eventId": "S01-H39", + "emotionMomentId": "", + "assetName": "S01-C10-P05-H39-phone-and-cold-meal-v1.jpg", + "caption": "长卷线座机又响,明远半坐半起去接;冷饭不再冒热气,墙钟早过了饭点。", + "secondaryCaption": "", + "interactionPrompt": "连续久坐接订席,正餐一拖再拖,这样妥当吗?", + "shortDialogue": "明远说:“接完这一个就吃。”电话那头又来一桌,他的筷子再次放下。", + "hotspot": { + "x": 18, + "y": 0, + "w": 70, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS003", + "S01-C10-MS004", + "S01-C10-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P06", + "type": "event", + "eventId": "S01-H40", + "emotionMomentId": "", + "assetName": "S01-C10-P06-H40-table-not-usable-v1.jpg", + "caption": "秦师傅说桌一直给自己人留着,可菜筐、酒箱和账本压满桌面,他的饭盒也无处放。", + "secondaryCaption": "", + "interactionPrompt": "口头说“给自己人留桌”,实际长期堆物不能坐,这样妥当吗?", + "shortDialogue": "秦师傅说:“这桌一直留着呢。”女炊事员指指酒箱:“留给谁了,酒瓶?”", + "hotspot": { + "x": 20, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS006", + "S01-C10-MS007", + "S01-C10-MS014" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM10", + "assetName": "S01-C10-P07-EM10-put-meal-down-v1.jpg", + "caption": "座机又响,明远一手拿听筒,一手端冷饭盒,桌上没有落碗处。", + "secondaryCaption": "", + "interactionPrompt": "你想怎样帮他把这一顿饭重新放回生活里?", + "shortDialogue": "", + "hotspot": { + "x": 20, + "y": 0, + "w": 66, + "h": 99 + }, + "programDetailInset": { + "label": "饭盒近景", + "x": 44, + "y": 45, + "w": 33, + "h": 37, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C10-MS005", + "S01-C10-MS012", + "S01-C10-MS013", + "S01-C10-TE900" + ], + "tableEcho": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P08-plan-line-last-stool-v1.jpg", + "caption": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-TE900", + "S01-C10-MS014" + ], + "tableEcho": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "cliffhanger": "规划红线压过桌脚,最后一张凳子被推走。字幕写着:“五年后,这张图再次展开。”" + } + ] + }, + "S01-C11": { + "pages": [ + { + "pageId": "S01-C11-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P01-pen-above-paper-v1.jpg", + "caption": "旧图再次展开,秦师傅握着笔,先看旧桌,再看等他签字的人。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MT000", + "S01-C11-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P02-opening-ensemble-v1.jpg", + "caption": "小满擦展柜,秦师傅悬笔,施工负责人分通道;林秀兰与唐守安都在等他的决定。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在保存,谁在检查,谁在让路,谁又握着最后要落的笔。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MS002", + "S01-C11-MS003", + "S01-C11-MS004", + "S01-C11-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P03", + "type": "event", + "eventId": "S01-H41", + "emotionMomentId": "", + "assetName": "S01-C11-P03-H41-display-and-seat-v1.jpg", + "caption": "小满把玻璃与展板擦亮,回头才看见旧长凳正被搬走,林秀兰还在等她看那块空位。", + "secondaryCaption": "", + "interactionPrompt": "只把“健康、互助”做成展板,实际服务没有变化,这样妥当吗?", + "shortDialogue": "小满说:“这些字要留下。”林秀兰回她:“字留下了,人坐哪儿,也得留下。”", + "hotspot": { + "x": 20, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS002", + "S01-C11-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P04", + "type": "event", + "eventId": "S01-H42", + "emotionMomentId": "", + "assetName": "S01-C11-P04-H42-inspect-old-table-v1.jpg", + "caption": "秦师傅摸着旧桌沿想直接搬走,卷尺和检查表已递到手边,松动桌腿还没有人作结论。", + "secondaryCaption": "", + "interactionPrompt": "不检查结构安全,因怀旧就直接继续给客人使用,这样妥当吗?", + "shortDialogue": "秦师傅摸着桌沿:“它撑了几十年。”施工负责人说:“先查清还能不能安全撑今天。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS001", + "S01-C11-MS006", + "S01-C11-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P05", + "type": "event", + "eventId": "S01-H43", + "emotionMomentId": "", + "assetName": "S01-C11-P05-H43-clear-passage-v1.jpg", + "caption": "施工负责人亲手合上材料区围挡,又把通道口让开;唐守安和员工抬着长凳顺利通过。", + "secondaryCaption": "", + "interactionPrompt": "施工材料与人员必经路线分开,并保留清楚通道,这样更稳妥吗?", + "shortDialogue": "施工负责人说:“人走这一边,材料走那一边。看得见的通道,才真能走。”", + "hotspot": { + "x": 18, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS004", + "S01-C11-MS005", + "S01-C11-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P06", + "type": "event", + "eventId": "S01-H44", + "emotionMomentId": "", + "assetName": "S01-C11-P06-H44-pen-before-signature-v1.jpg", + "caption": "秦师傅把旧物清单又看一遍,笔尖终于落下半寸;林秀兰站在侧后,没有替他拿笔。", + "secondaryCaption": "", + "interactionPrompt": "白天签下旧物处置后,夜里仍保存完整桌板、铜牌与钥匙记录,这样更稳妥吗?", + "shortDialogue": "林秀兰问:“你不是说这桌不动吗?”秦师傅说:“饭店得活下来,人才能回来。”", + "hotspot": { + "x": 20, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS006", + "S01-C11-MS007", + "S01-C11-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM11", + "assetName": "S01-C11-P07-EM11-wrap-and-record-v1.jpg", + "caption": "夜里,秦师傅把桌板、铜牌和两枚破票逐件包好;空白记录还等他留下些什么。", + "secondaryCaption": "", + "interactionPrompt": "除了保存旧物,你想请秦师傅再留下些什么?", + "shortDialogue": "", + "hotspot": { + "x": 21, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS009", + "S01-C11-MS010", + "S01-C11-MS011", + "S01-C11-TE900" + ], + "tableEcho": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P08-can-we-open-now-v1.jpg", + "caption": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MS012", + "S01-C11-MS013", + "S01-C11-MS014" + ], + "tableEcho": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "cliffhanger": "十八年后,小满认出抽屉里的旧饭票夹。她没有擅自拿,只第三次问:“现在能开吗?”" + } + ] + }, + "S01-C12": { + "pages": [ + { + "pageId": "S01-C12-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P01-key-pauses-half-turn-v1.jpg", + "caption": "爷爷点了头,小满才取下饭票夹;钥匙转到一半,她又停下来说明。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MT000", + "S01-C12-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P02-modern-table-ensemble-v1.jpg", + "caption": "旧桌板被人擦亮,纸单被人铺开;赵伯按加号,明远拿起信息卡,秦师傅仍站在后厨门里。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在核对旧物,谁把点餐方式摆出来,谁又想替一家人一次安排完。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MS002", + "S01-C12-MS003", + "S01-C12-MS005", + "S01-C12-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P03", + "type": "event", + "eventId": "S01-H45", + "emotionMomentId": "", + "assetName": "S01-C12-P03-H45-provenance-chain-v1.jpg", + "caption": "小满先拍桌板全貌,再拍红漆、孔位和票据;林秀兰擦板,秦师傅把纸筒递到手边。", + "secondaryCaption": "", + "interactionPrompt": "征得秦师傅同意后开柜,并用红漆、孔位、板字、照片和票据共同核对,这样更稳妥吗?", + "shortDialogue": "小满说:“这回能开吗?”秦师傅点头。她又说:“一件一件记,别让一个孔替所有证据说话。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS001", + "S01-C12-MS002", + "S01-C12-MS003", + "S01-C12-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P04", + "type": "event", + "eventId": "S01-H46", + "emotionMomentId": "", + "assetName": "S01-C12-P04-H46-real-choice-counter-v1.jpg", + "caption": "小满把大字纸单推向老人,员工当面等他开口;扫码牌在旁,现金盒也正常打开。", + "secondaryCaption": "", + "interactionPrompt": "在扫码之外保留大字纸单、人工点餐,并保持正常现金收款,这样更稳妥吗?", + "shortDialogue": "小满说:“会扫码就扫,想看纸单就看纸单;有人在这儿帮,现金也照常收。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS005", + "S01-C12-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P05", + "type": "event", + "eventId": "S01-H47", + "emotionMomentId": "", + "assetName": "S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg", + "caption": "十个人围着真实饭桌,赵伯熟练按下加号;乐乐只按住自己的手,先问每份多大。", + "secondaryCaption": "", + "interactionPrompt": "因“七十周年不能空”继续加菜,这样妥当吗?", + "shortDialogue": "赵伯说:“十四不好听,再来俩。”乐乐按住减号:“十个人十四道,已经不是数学问题了。”", + "hotspot": { + "x": 15, + "y": 0, + "w": 69, + "h": 99 + }, + "programEvidenceInset": { + "kind": "order-count", + "kicker": "画中近景", + "screenLabel": "手机点单", + "countLabel": "已点", + "countValue": 14, + "countUnit": "道", + "plusGlyph": "+", + "note": "赵伯还在加菜", + "anchor": "top-right", + "ariaLabel": "画中近景:手机显示已点十四道,赵伯还要按加号继续加菜。" + }, + "audioCueIds": [ + "S01-C12-MS007", + "S01-C12-MS008", + "S01-C12-MS009" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P06", + "type": "event", + "eventId": "S01-H48", + "emotionMomentId": "", + "assetName": "S01-C12-P06-H48-card-is-not-a-promise-v1.jpg", + "caption": "明远把信息卡往父亲面前一放,像找到了省心答案;乐乐却指着空白栏,等他把话听完。", + "secondaryCaption": "", + "interactionPrompt": "看到“烹调方式:炖;本份约供2—3人;可选小份”,就理解成“健康保证”,这样妥当吗?", + "shortDialogue": "明远说:“写得这么健康,多点一份没事。”乐乐问:“它说怎么做、多大份,又没说能治病吧?”", + "hotspot": { + "x": 18, + "y": 0, + "w": 67, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS010", + "S01-C12-MS011", + "S01-C12-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM12", + "assetName": "S01-C12-P07-EM12-hand-choice-back-v1.jpg", + "caption": "手机、语音、纸单和服务员都在;乐乐把纸单放到赵伯手边,明远终于停下来等他开口。", + "secondaryCaption": "", + "interactionPrompt": "信息都在了,最后一步怎样交还给赵伯?", + "shortDialogue": "", + "hotspot": { + "x": 18, + "y": 0, + "w": 67, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS005", + "S01-C12-MS006", + "S01-C12-TE900" + ], + "tableEcho": "让人看懂、让人开口,才算把选择递到手里。", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P08-rolled-speech-cliffhanger-v1.jpg", + "caption": "让人看懂、让人开口,才算把选择递到手里。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MS013", + "S01-C12-MS014-ATTR", + "S01-C12-MS014", + "S01-C12-MS015", + "S01-C12-MS016" + ], + "tableEcho": "让人看懂、让人开口,才算把选择递到手里。", + "cliffhanger": "秦师傅把稿子重新卷起,只说:“先上菜。”林秀兰看见末行写着“请晚班的人原谅我”。" + } + ] + }, + "S01-C13": { + "pages": [ + { + "pageId": "S01-C13-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P01-three-chopsticks-collide-v1.jpg", + "caption": "乐乐刚说饱,三双公筷却同时伸来;只听“叮”的一声,桌边静了半拍。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MT000", + "S01-C13-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P02-opening-ensemble-v1.jpg", + "caption": "乐乐护碗,明远推盘,赵伯的姓名牌去了侧桌;小满手里的软尺,还没有展开。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁还在替人夹,谁把自己的菜推过去,谁又让姓名牌先离了席。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MS002", + "S01-C13-MS003", + "S01-C13-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P03", + "type": "event", + "eventId": "S01-H49", + "emotionMomentId": "", + "assetName": "S01-C13-P03-H49-ask-before-serving-v1.jpg", + "caption": "三位长辈都想照顾孩子,却没有一个先问;公筷干净,也不能替乐乐说“要”。", + "secondaryCaption": "", + "interactionPrompt": "不问乐乐就轮流替他夹菜,这样妥当吗?", + "shortDialogue": "赵伯笑:“还排上号了。”乐乐护住碗:“公筷也是筷子,先问我呀!”", + "hotspot": { + "x": 14, + "y": 3, + "w": 70, + "h": 94 + }, + "audioCueIds": [ + "S01-C13-MS001", + "S01-C13-MS002", + "S01-C13-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P04", + "type": "event", + "eventId": "S01-H50", + "emotionMomentId": "", + "assetName": "S01-C13-P04-H50-name-card-leaves-first-v1.jpg", + "caption": "小满想把照顾安排周全,却先把姓名牌移去侧桌;赵伯本人仍坐在大家中间。", + "secondaryCaption": "", + "interactionPrompt": "因担心赵伯,未经商量就把他安排到隔开的桌上,这样妥当吗?", + "shortDialogue": "赵伯探身说:“我人还在这儿,牌先被请走了?”小满的手停在半空。", + "hotspot": { + "x": 43, + "y": 1, + "w": 54, + "h": 98 + }, + "programDetailInset": { + "label": "桌边近景", + "x": 65, + "y": 36, + "w": 35, + "h": 39, + "anchor": "top-right", + "labelAnchor": "bottom-left" + }, + "audioCueIds": [ + "S01-C13-MS006", + "S01-C13-MS007", + "S01-C13-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P05", + "type": "event", + "eventId": "S01-H51", + "emotionMomentId": "", + "assetName": "S01-C13-P05-H51-family-double-standard-v1.jpg", + "caption": "明远把自己的甜饮举到嘴边,另一只手还搭在推给乐乐的菜盘上;乐乐抬眼看他。", + "secondaryCaption": "", + "interactionPrompt": "大人提醒孩子少喝,自己却仍举着甜饮,这样妥当吗?", + "shortDialogue": "明远说:“饮料少喝。”乐乐看着他的瓶子:“爸爸,那咱们一起少喝,好吗?”", + "hotspot": { + "x": 18, + "y": 0, + "w": 68, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS004", + "S01-C13-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P06", + "type": "event", + "eventId": "S01-H52", + "emotionMomentId": "", + "assetName": "S01-C13-P06-H52-tape-stays-rolled-v1.jpg", + "caption": "小满看着姓名牌和卷起的软尺,没有先量侧桌;这一回,她决定先问坐桌的人。", + "secondaryCaption": "", + "interactionPrompt": "不先挪人,先问赵伯想坐哪里,再调整共同桌,这样更稳妥吗?", + "shortDialogue": "小满问:“赵伯,您想坐哪儿?”赵伯把椅子往里收了收:“我还坐大家旁边。”", + "hotspot": { + "x": 24, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS009", + "S01-C13-MS010", + "S01-C13-MS011", + "S01-C13-MS012", + "S01-C13-MS013", + "S01-C13-MS014", + "S01-C13-MS015" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM13", + "assetName": "S01-C13-P07-EM13-listen-to-child-v1.jpg", + "caption": "三双公筷终于都停下。乐乐还护着碗,等大人第一次不替他说话。", + "secondaryCaption": "", + "interactionPrompt": "这一回,家里人怎样让乐乐自己选?", + "shortDialogue": "", + "hotspot": { + "x": 24, + "y": 0, + "w": 54, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS010", + "S01-C13-MS011", + "S01-C13-MS012", + "S01-C13-TE900" + ], + "tableEcho": "这一次,大人终于听孩子把话说完。", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P08", + "type": "memory-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P08-cook-crosses-threshold-v1.jpg", + "caption": "为你好之前,先听一句“我要不要”;照顾也要把选择还给人。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MS016", + "S01-C13-MS017", + "S01-C13-MS018" + ], + "tableEcho": "", + "cliffhanger": "秦师傅端起一锅白菜热汤面,终于跨过躲了整晚的后厨门槛。" + } + ] + }, + "S01-C14": { + "pages": [ + { + "pageId": "S01-C14-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P01-lid-opens-old-scent-v1.jpg", + "caption": "锅盖一揭,白菜热汤面冒起白汽;赵伯一句“就这”,忽然停在了半截。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MT000", + "S01-C14-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P02-responsibility-ensemble-v1.jpg", + "caption": "秦师傅扶住旧板,林秀兰排开证据;唐守安停在门框,赵伯从普通座端茶起身。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在说自己的责任,谁只补证据,谁没有往主位走,谁仍在桌上举杯。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MS002", + "S01-C14-MS003", + "S01-C14-MS004", + "S01-C14-MS005", + "S01-C14-MS006", + "S01-C14-MS007-ATTR", + "S01-C14-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P03", + "type": "event", + "eventId": "S01-H53", + "emotionMomentId": "", + "assetName": "S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg", + "caption": "秦师傅只讲白菜、面、热汤、做法与份量;旧味能留人情,不能替代治疗。", + "secondaryCaption": "", + "interactionPrompt": "因为是老菜,就宣传成“祖传降糖面”,这样妥当吗?", + "shortDialogue": "秦师傅说:“就是白菜、面和一锅热汤。讲做法、讲份量,别给它封神。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 68, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS001", + "S01-C14-MS002", + "S01-C14-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P04", + "type": "event", + "eventId": "S01-H54", + "emotionMomentId": "", + "assetName": "S01-C14-P04-H54-evidence-chain-v1.jpg", + "caption": "林秀兰把两枚破票、铜牌、红漆、孔位、板字与照片逐项核对;没有一件物证独自定案。", + "secondaryCaption": "", + "interactionPrompt": "把票据、铜牌、暗红漆、孔位、板字和照片一起核对,这样更稳妥吗?", + "shortDialogue": "林秀兰说:“每件东西只说自己知道的那一段,合起来,故事才站得稳。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 71, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS004", + "S01-C14-MS007-ATTR", + "S01-C14-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P05", + "type": "event", + "eventId": "S01-H55", + "emotionMomentId": "", + "assetName": "S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg", + "caption": "唐守安停在门框,没有去主位;赵伯拉开身边的普通座,秦师傅的话仍由秦师傅说完。", + "secondaryCaption": "", + "interactionPrompt": "大家请他坐主位,他选择普通座并让秦师傅自己说完,这样更稳妥吗?", + "shortDialogue": "赵伯喊:“给唐大夫留个座。”唐守安摆手:“普通座就好,先听老秦把话说完。”", + "hotspot": { + "x": 48, + "y": 1, + "w": 48, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS008", + "S01-C14-MS009", + "S01-C14-MS010" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P06", + "type": "event", + "eventId": "S01-H56", + "emotionMomentId": "", + "assetName": "S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg", + "caption": "赵伯把蓝花盖碗转半圈,主动同大家碰杯;不用酒证明能耐,他也没有离开这张桌。", + "secondaryCaption": "", + "interactionPrompt": "用茶参加碰杯,也不再要求别人喝酒,这样更稳妥吗?", + "shortDialogue": "赵伯说:“这回我当个不劝酒、不硬撑的骨干。老伙计,茶也算一杯。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 65, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS011", + "S01-C14-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM14", + "assetName": "S01-C14-P07-EM14-watch-face-down-v1.jpg", + "caption": "父子同高坐下,那只曾让问话匆匆结束的手表,还亮在两人之间。", + "secondaryCaption": "", + "interactionPrompt": "唐守安怎样让儿子把那句旧话真正说完?", + "shortDialogue": "", + "hotspot": { + "x": 8, + "y": 1, + "w": 84, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS012", + "S01-C14-MS013", + "S01-C14-MS014", + "S01-C14-MS015", + "S01-C14-TE900" + ], + "tableEcho": "会解决问题的人,也可以学着先听一会儿。", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P08", + "type": "memory-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P08-measure-the-empty-place-v1.jpg", + "caption": "会解决问题,也要先听一会儿;桌子能否回来,先看那块空位最终坐回谁。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MS016", + "S01-C14-MS017" + ], + "tableEcho": "", + "cliffhanger": "秦师傅问:“十五桌还能回来吗?”小满没有回答,先拿软尺重新量空位。" + } + ] + }, + "S01-C15": { + "pages": [ + { + "pageId": "S01-C15-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P01-chair-opens-for-late-workers-v1.jpg", + "caption": "三周后,晚市将收;小满先拉开椅子,门口那几个人终于不用再等。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C15-MT000", + "S01-C15-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P02-everyone-makes-room-ensemble-v1.jpg", + "caption": "小满把椅子朝共同桌拉开,赵伯抬手招呼;两位晚班工友走进门,来晚的人也有位置。", + "secondaryCaption": "", + "interactionPrompt": "看看小满把椅子拉向哪里,再看门口的人正往哪里走。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C15-MS002", + "S01-C15-MS003", + "S01-C15-MS004", + "S01-C15-MS005", + "S01-C15-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P03", + "type": "event", + "eventId": "S01-H57", + "emotionMomentId": "", + "assetName": "S01-C15-P03-H57-three-real-portions-v1.jpg", + "caption": "小甄先看来了几个人,再把普通份、小份、半份三种真实餐盘推到大家眼前。", + "secondaryCaption": "", + "interactionPrompt": "菜单提供多种份量并写建议用餐人数,这样更稳妥吗?", + "shortDialogue": "小甄说:“先看几个人、每份多大。少点不够再添,比猜一桌需要多少更踏实。”", + "hotspot": { + "x": 17, + "y": 1, + "w": 55, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS003", + "S01-C15-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P04", + "type": "event", + "eventId": "S01-H58", + "emotionMomentId": "", + "assetName": "S01-C15-P04-H58-water-within-reach-v1.jpg", + "caption": "小甄把白水壶放到共同桌边;其他饮品仍在侧柜,先问本人,再决定要不要。", + "secondaryCaption": "", + "interactionPrompt": "默认让白水容易取得,酒和甜饮由本人选择,不替所有人自动摆上,这样更稳妥吗?", + "shortDialogue": "小甄说:“白水先放手边,其他饮品看清信息、问过本人,再决定要不要。”", + "hotspot": { + "x": 41, + "y": 1, + "w": 49, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS003", + "S01-C15-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P05", + "type": "event", + "eventId": "S01-H59", + "emotionMomentId": "", + "assetName": "S01-C15-P05-H59-ask-before-serving-v1.jpg", + "caption": "明远的公筷停在乐乐碗外。他先看孩子的脸,再问:“这个还要吗?”", + "secondaryCaption": "", + "interactionPrompt": "夹菜前先问乐乐还要不要,这样更稳妥吗?", + "shortDialogue": "明远问:“这个还要吗?”乐乐点头:“要一点,我自己夹。”明远把公筷递过去。", + "hotspot": { + "x": 12, + "y": 1, + "w": 58, + "h": 98 + }, + "programDetailInset": { + "label": "公筷停住", + "x": 22, + "y": 38, + "w": 51, + "h": 57, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C15-MS007", + "S01-C15-MS008", + "S01-C15-MS009", + "S01-C15-MS010", + "S01-C15-MS011", + "S01-C15-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P06", + "type": "event", + "eventId": "S01-H60", + "emotionMomentId": "", + "assetName": "S01-C15-P06-H60-check-before-packing-v1.jpg", + "caption": "员工没有把剩菜一股脑装盒;她逐盘询问,也把保存和再加热提示一项项说明。", + "secondaryCaption": "", + "interactionPrompt": "所有剩菜不分情况都必须打包,这样妥当吗?", + "shortDialogue": "员工问:“这些要带走吗?我先把能不能保存、怎么再加热给您说清。”", + "hotspot": { + "x": 31, + "y": 1, + "w": 58, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS016" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM15", + "assetName": "S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg", + "caption": "同一机位里,人已坐满;老吕带来的右缺口蓝边旧碗,还是空的。", + "secondaryCaption": "", + "interactionPrompt": "这只旧碗怎样留在今天的第十五桌边?", + "shortDialogue": "", + "hotspot": { + "x": 12, + "y": 1, + "w": 76, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS013", + "S01-C15-MS014", + "S01-C15-MS015", + "S01-C15-MS016", + "S01-C15-TE900", + "S01-C15-MS017" + ], + "tableEcho": "桌子回来了,人也都坐回来了。", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P08", + "type": "memory-season-close", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P08-people-seated-season-close-v2.jpg", + "caption": "第一回少的桌,如今有人坐回来了。铜牌回到桌沿,晚班工友也坐回来了。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "programDetailInset": { + "label": "秦师傅", + "x": 72, + "y": 7, + "w": 25, + "h": 44, + "anchor": "top-right" + }, + "audioCueIds": [ + "S01-C15-MS016", + "S01-C15-TE900", + "S01-C15-MS017", + "S01-C15-MS018" + ], + "tableEcho": "", + "cliffhanger": "快门按下,旧铜牌“15”重新固定。原来缺的不是一张桌,是这些人应该有的位置。" + } + ] + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/data/releaseAssetManifest.js b/TUICallKit-Vue3/native/tang-detective/package-game/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-game/data/releaseAssetManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/data/remotePageAudioManifest.js b/TUICallKit-Vue3/native/tang-detective/package-game/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-game/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapter.js b/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapter.json b/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapter.wxml b/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapter.wxss b/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapterLayout.js b/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapterPages.js b/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-game/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/utils/assetManager.js b/TUICallKit-Vue3/native/tang-detective/package-game/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-game/utils/assetManager.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/utils/comicPageModel.js b/TUICallKit-Vue3/native/tang-detective/package-game/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-game/utils/comicPageModel.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/package-game/utils/comicReaderState.js b/TUICallKit-Vue3/native/tang-detective/package-game/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/package-game/utils/comicReaderState.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/pages/cast/cast.js b/TUICallKit-Vue3/native/tang-detective/pages/cast/cast.js new file mode 100644 index 0000000..09241dc --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/cast/cast.js @@ -0,0 +1,119 @@ +const rawCast = require('../../data/cast') +const { + getSettings, + saveSettings, +} = require('../../utils/storage') + +const readerDetails = { + 'tang-shouan': { + relationship: '唐明远的父亲、乐乐的爷爷,也是桂香老邻居信得过的唐大夫。', + visual: '常穿素色深靛蓝外套,背着旧棕布卫生包。衣着朴素,像每天都会遇见的邻家大夫。', + poseCount: 5, + }, + 'qin-zhicheng': { + relationship: '秦小满的长辈,桂香食堂和饭店里掌勺多年的秦师傅。', + visual: '宽头、粗颈、宽肩,前臂厚实;穿中式粗棉厨工褂、白围裙和低矮布帽。', + poseCount: 5, + }, + 'lin-xiulan': { + relationship: '桂香厂老工友,也是饭票、账本和旧物的保管人。', + visual: '常穿枣红或深棕工作外套。她不急着训人,更愿意用行动把事情说明白。', + poseCount: 5, + }, + 'zhao-jianguo': { + relationship: '大家叫他赵伯,是桂香厂出了名的劳动骨干和老工友。', + visual: '青年时穿结实工装,老年戴深蓝便帽、捧盖碗茶;豪爽有劲,也保留普通人的体面。', + poseCount: 5, + }, + 'tang-mingyuan': { + relationship: '唐守安的儿子、乐乐的父亲,和许多中年人一样忙工作也顾家。', + visual: '衣着随年代变化,腰腹和颈围也慢慢有了变化。这是多年生活留下的痕迹,不拿他的身形开玩笑。', + poseCount: 4, + }, + 'qin-xiaoman': { + relationship: '秦师傅的晚辈,如今接手照看桂香饭店。', + visual: '穿简洁的现代中式工作装,常带着软尺、纸质座位单和调台记录。', + poseCount: 4, + }, + lele: { + relationship: '唐明远的儿子、唐守安的孙子,是家里最敢把问题问出来的人。', + visual: '圆润、活泼、讨人喜欢。大人从家庭习惯上找原因,不把责任推给孩子。', + poseCount: 4, + }, + xiaozhen: { + relationship: '在现代生活里陪大家整理健康信息的人,需要时提醒大家联系专业医护。', + visual: '灰绿色开衫、白衬衫、低马尾,手里拿着记录夹;总在需要时陪一程,不抢别人的故事。', + poseCount: 4, + }, +} + +const cast = rawCast.map((character) => ({ + ...character, + relationship: readerDetails[character.id].relationship, + readerRole: character.id === 'xiaozhen' + ? '在故事需要回顾生活习惯时陪大家理一理;遇到拿不准的健康问题,会提醒大家联系专业医护,不替医生作决定。' + : character.role, + readerVisual: readerDetails[character.id].visual, + poseCount: readerDetails[character.id].poseCount, +})) + +Page({ + data: { + cast, + selected: null, + fontScale: 'large', + castListEnded: false, + }, + + onLoad() { + const settings = getSettings() + this.setData({ + fontScale: settings.fontScale === 'xlarge' ? 'xlarge' : 'large', + castListEnded: false, + }) + }, + + selectCharacter(event) { + const id = event.currentTarget.dataset.id + const selected = cast.find((character) => character.id === id) + this.setData({ selected }) + }, + + closeCharacter() { + this.setData({ selected: null }) + }, + + markListEnd() { + if (!this.data.castListEnded) { + this.setData({ castListEnded: true }) + } + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ fontScale }) + }, + + noop() {}, + + goBack() { + wx.reLaunch({ url: '/pages/home/home' }) + }, + + onShareAppMessage() { + return { + title: '唐侦探人物画谱:认识桂香故事里的人', + path: '/pages/cast/cast', + } + }, + + onShareTimeline() { + return { + title: '唐侦探人物画谱:认识桂香故事里的人', + query: '', + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/pages/cast/cast.json b/TUICallKit-Vue3/native/tang-detective/pages/cast/cast.json new file mode 100644 index 0000000..28d4548 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/cast/cast.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "人物画谱", + "pageOrientation": "landscape" +} diff --git a/TUICallKit-Vue3/native/tang-detective/pages/cast/cast.wxml b/TUICallKit-Vue3/native/tang-detective/pages/cast/cast.wxml new file mode 100644 index 0000000..0022a5b --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/cast/cast.wxml @@ -0,0 +1,119 @@ + + + + + 桂香里的熟面孔 + 人物画谱 + + + + + + + + + + 点开一位人物,先认清他和家里人的关系,再看看他在桂香里的故事。 + + + + + + + + + + + {{item.name}} + {{item.eras}} + + {{item.title}} + {{item.relationship}} + 点开看故事 + + + + + + + {{castListEnded ? '人物都看见了' : '向下翻,还有更多人物'}} + + + + + + + + + + + + + + + {{selected.eras}} · {{selected.title}} + {{selected.name}} + + + + + + {{selected.relationship}} + + {{selected.readerRole}} + + {{selected.readerVisual}} + + + + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/pages/cast/cast.wxss b/TUICallKit-Vue3/native/tang-detective/pages/cast/cast.wxss new file mode 100644 index 0000000..3103818 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/cast/cast.wxss @@ -0,0 +1,660 @@ +.cast-shell { + display: flex; + height: 100vh; + gap: 14rpx; + flex-direction: column; + overflow: hidden; +} + +.cast-head { + display: grid; + min-height: 62px; + flex: 0 0 auto; + grid-template-columns: 110px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 6px 10px; +} + +.cast-back, +.font-button, +.modal-close, +.modal-done { + min-width: 48px; + min-height: 48px; +} + +.cast-back { + width: 100%; + min-width: 0; + padding-right: 8px; + padding-left: 8px; +} + +.cast-heading-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.cast-title { + overflow: hidden; + font-size: 27px; + font-weight: 850; + letter-spacing: 2px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cast-heading-copy .eyebrow { + overflow: hidden; + font-size: 15px; + letter-spacing: 1px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cast-head-actions { + display: flex; + align-items: center; + gap: 10rpx; + justify-self: end; +} + +.font-button { + white-space: nowrap; +} + +/* The WeChat capsule occupies this part of a custom landscape title bar. */ +.capsule-safe-space { + width: 102px; + height: 40px; + flex: 0 0 102px; +} + +.cast-scroll { + min-height: 0; + flex: 1; +} + +.cast-intro { + padding: 2px 6px 10px; + color: #f4e5bd; + font-size: 17px; + line-height: 1.5; +} + +.cast-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 0 2px 20px; +} + +.cast-card { + display: grid; + width: 100%; + min-width: 0; + min-height: 144px; + overflow: hidden; + grid-template-columns: 132px minmax(0, 1fr); + text-align: left; + border-radius: 8px; +} + +.cast-card-hover { + transform: translateY(2rpx); + opacity: 0.88; +} + +.cast-image-wrap { + display: flex; + min-height: 144px; + align-items: center; + justify-content: center; + overflow: hidden; + background: #d4c29a; + border-right: 5px solid var(--cinnabar); +} + +.cast-portrait-window, +.modal-portrait-window { + position: relative; + overflow: hidden; + background: #dfcfaa; + border: 1px solid rgba(98, 65, 40, 0.35); +} + +.cast-portrait-window { + height: 126px; +} + +.cast-portrait-window.poses-5 { + width: 45px; +} + +.cast-portrait-window.poses-4 { + width: 56px; +} + +.cast-image { + position: absolute; + top: 0; + left: 0; + width: 224px; + height: 126px; +} + +.cast-copy { + display: flex; + justify-content: center; + padding: 10px 13px; + flex-direction: column; +} + +.name-row { + display: flex; + align-items: baseline; + gap: 8rpx; + flex-wrap: wrap; +} + +.cast-name { + font-size: 23px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.eras { + color: var(--muted); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 14px; + white-space: nowrap; +} + +.cast-role-title { + margin-top: 4px; + color: #8d3028; + font-size: 17px; + font-weight: 750; + line-height: 1.3; +} + +.cast-relationship { + display: -webkit-box; + margin-top: 5px; + overflow: hidden; + color: #554333; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 15px; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.cast-hint { + margin-top: 7px; + color: var(--muted); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 15px; + font-weight: 700; + line-height: 1.35; +} + +.list-scroll-guide { + display: flex; + min-height: 40px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + gap: 10px; + color: #f4e5bd; + background: rgba(58, 40, 28, 0.94); + border: 1px solid rgba(222, 194, 139, 0.55); + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 750; +} + +.scroll-guide-mark { + color: #f0c86b; + font-size: 24px; + line-height: 1; +} + +.modal-backdrop { + position: fixed; + z-index: 100; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + calc(10px + constant(safe-area-inset-top)) + calc(112px + constant(safe-area-inset-right)) + calc(10px + constant(safe-area-inset-bottom)) + calc(10px + constant(safe-area-inset-left)); + padding: + calc(10px + env(safe-area-inset-top)) + calc(112px + env(safe-area-inset-right)) + calc(10px + env(safe-area-inset-bottom)) + calc(10px + env(safe-area-inset-left)); + background: rgba(19, 13, 9, 0.82); +} + +.character-modal { + position: relative; + display: grid; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(240px, 42%) minmax(0, 58%); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.modal-image-wrap { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + background: #d1bf96; + border-right: 6rpx solid #7d2a23; +} + +.modal-portrait-window { + height: 560px; +} + +.modal-portrait-window.poses-5 { + width: 200px; +} + +.modal-portrait-window.poses-4 { + width: 249px; +} + +.modal-image { + position: absolute; + top: 0; + left: 0; + width: 996px; + height: 560px; +} + +.modal-copy { + position: relative; + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(76px, auto) minmax(0, 1fr) minmax(68px, auto); +} + +.modal-head { + display: flex; + min-height: 76px; + align-items: center; + padding: 12rpx 88px 8rpx 22rpx; + border-bottom: 2rpx solid rgba(125, 42, 35, 0.22); +} + +.modal-heading { + display: flex; + min-width: 0; + overflow: hidden; + flex: 1; + flex-direction: column; +} + +.modal-heading .eyebrow { + overflow: hidden; + font-size: 18rpx; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modal-name { + margin-top: 2rpx; + font-size: 42rpx; + font-weight: 900; + line-height: 1.12; +} + +.modal-close { + position: absolute; + z-index: 3; + top: 12px; + right: 12px; + display: flex; + width: 68px; + height: 48px; + min-width: 68px; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 0 8px; + color: #6f201b; + background: #fff8e5; + border: 2rpx solid #9f6d54; + border-radius: 8px; + box-shadow: 0 4px 10px rgba(48, 37, 28, 0.18); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; +} + +.modal-story-scroll { + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.modal-story { + display: flex; + padding: 12rpx 24rpx 28rpx; + flex-direction: column; +} + +.section-label { + margin-top: 10rpx; + color: #8d3028; + font-size: 22rpx; + font-weight: 850; +} + +.modal-text { + margin-top: 6rpx; + color: #4f3e30; + font-size: 23rpx; + line-height: 1.55; +} + +.modal-done { + width: 100%; + min-width: 48px; + min-height: 48px; + height: 48px; + padding: 0 14px; + font-size: 18px; +} + +.modal-footer { + display: flex; + min-height: 68px; + align-items: center; + padding: 8px 14px 12px; + background: rgba(243, 229, 189, 0.96); + border-top: 2rpx solid rgba(125, 42, 35, 0.22); +} + +.font-xlarge .cast-title { + font-size: 35rpx; +} + +.font-xlarge .cast-intro, +.font-xlarge .cast-role-title, +.font-xlarge .section-label { + font-size: 20px; +} + +.font-xlarge .cast-name { + font-size: 26px; +} + +.font-xlarge .eras, +.font-xlarge .cast-hint, +.font-xlarge .modal-heading .eyebrow { + font-size: 17px; +} + +.font-xlarge .cast-relationship, +.font-xlarge .list-scroll-guide { + font-size: 18px; +} + +.font-xlarge .modal-name { + font-size: 46rpx; +} + +.font-xlarge .modal-text { + font-size: 26rpx; +} + +@media (max-height: 620px) { + .cast-shell { + gap: 8px; + } + + .cast-scroll { + padding-left: 18px; + } + + .cast-head { + min-height: 62px; + grid-template-columns: 96px minmax(0, 1fr) auto; + gap: 8px; + padding: 6px 8px; + } + + .cast-back, + .font-button { + min-height: 48px; + font-size: 17px; + } + + .font-button { + padding: 0 12px; + } + + .cast-title { + font-size: 25px; + } + + .cast-heading-copy .eyebrow { + font-size: 15px; + letter-spacing: 2px; + } + + .cast-intro { + padding: 0 4px 8px; + font-size: 17px; + } + + .cast-grid { + gap: 10px; + padding: 0 2px 16px; + } + + .cast-card { + min-height: 144px; + grid-template-columns: 132px minmax(0, 1fr); + } + + .cast-image-wrap { + min-height: 144px; + } + + .cast-copy { + padding: 8px 10px; + } + + .cast-name { + font-size: 22px; + } + + .eras { + font-size: 14px; + } + + .cast-role-title { + font-size: 17px; + } + + .cast-relationship { + font-size: 15px; + } + + .cast-hint { + margin-top: 6px; + font-size: 15px; + } + + .modal-head { + min-height: 68px; + padding: 8px 82px 6px 14px; + } + + .modal-copy { + grid-template-rows: minmax(68px, auto) minmax(0, 1fr) minmax(64px, auto); + } + + .modal-heading .eyebrow { + font-size: 14px; + letter-spacing: 1px; + } + + .modal-name { + font-size: 30px; + } + + .modal-story { + padding: 7px 16px 16px; + } + + .modal-close { + top: 10px; + right: 10px; + width: 64px; + min-width: 64px; + height: 48px; + min-height: 48px; + font-size: 17px; + } + + .modal-footer { + min-height: 64px; + padding: 8px 12px; + } + + .modal-done { + height: 48px; + min-height: 48px; + } + + .section-label { + margin-top: 7px; + font-size: 18px; + } + + .modal-text { + margin-top: 4px; + font-size: 18px; + line-height: 1.5; + } + + .modal-backdrop { + padding-right: calc(98px + constant(safe-area-inset-right)); + padding-left: calc(32px + constant(safe-area-inset-left)); + padding-right: calc(98px + env(safe-area-inset-right)); + padding-left: calc(32px + env(safe-area-inset-left)); + } + + .modal-portrait-window { + height: 280px; + } + + .modal-portrait-window.poses-5 { + width: 100px; + } + + .modal-portrait-window.poses-4 { + width: 125px; + } + + .modal-image { + width: 498px; + height: 280px; + } + + .font-xlarge .cast-title { + font-size: 28px; + } + + .font-xlarge .cast-intro, + .font-xlarge .cast-role-title, + .font-xlarge .section-label { + font-size: 20px; + } + + .font-xlarge .cast-name { + font-size: 25px; + } + + .font-xlarge .eras, + .font-xlarge .cast-hint, + .font-xlarge .modal-heading .eyebrow { + font-size: 16px; + } + + .font-xlarge .cast-relationship, + .font-xlarge .list-scroll-guide { + font-size: 17px; + } + + .font-xlarge .modal-name { + font-size: 33px; + } + + .font-xlarge .modal-text { + font-size: 21px; + } +} + +@media (max-width: 720px) and (orientation: landscape) { + .cast-head { + grid-template-columns: 96px minmax(0, 1fr) auto; + gap: 8px; + } + + .capsule-safe-space { + width: 88px; + flex-basis: 88px; + } + + .font-button { + min-width: 84px; + padding-right: 9px; + padding-left: 9px; + } + + .cast-heading-copy .eyebrow { + display: none; + } + + .cast-grid { + grid-template-columns: minmax(0, 1fr); + } + + .cast-card { + grid-template-columns: 150px minmax(0, 1fr); + } + + .modal-backdrop { + padding-right: calc(98px + env(safe-area-inset-right)); + } + + .character-modal { + grid-template-columns: minmax(210px, 40%) minmax(0, 60%); + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/pages/catalog/catalog.js b/TUICallKit-Vue3/native/tang-detective/pages/catalog/catalog.js new file mode 100644 index 0000000..a0aef33 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/catalog/catalog.js @@ -0,0 +1,102 @@ +const chapters = require('../../data/chapters') +const { + getProgress, + saveProgress, + resetStoryProgress, + getSettings, +} = require('../../utils/storage') +const { chapterRoute } = require('../../utils/chapterRoute') + +Page({ + data: { + chapters: [], + fontScale: 'large', + catalogListEnded: false, + }, + + onShow() { + const progress = getProgress() + const settings = getSettings() + const completed = new Set(progress.completedChapters) + const lastChapter = Number(progress.lastChapter) || 1 + const completedHotspots = progress.completedHotspots || {} + this.setData({ + chapters: chapters.map((chapter) => ({ + ...chapter, + completed: completed.has(chapter.chapterId), + current: chapter.number === lastChapter, + readingLabel: completed.has(chapter.chapterId) + ? '重新翻看这一回' + : chapter.number === lastChapter + ? '上次读到这里' + : '翻开这一回', + eventCount: Array.isArray(completedHotspots[chapter.chapterId]) + ? completedHotspots[chapter.chapterId].length + : 0, + })), + lastChapter, + fontScale: settings.fontScale === 'xlarge' ? 'xlarge' : 'large', + catalogListEnded: false, + }) + }, + + markListEnd() { + if (!this.data.catalogListEnded) { + this.setData({ catalogListEnded: true }) + } + }, + + openChapter(event) { + const chapter = Number(event.currentTarget.dataset.chapter) + const progress = getProgress() + const chapterMeta = chapters.find((item) => item.number === chapter) + const replay = Boolean( + chapterMeta + && progress.completedChapters.includes(chapterMeta.chapterId), + ) + progress.lastChapter = chapter + saveProgress(progress) + wx.navigateTo({ + url: chapterRoute(chapter, { replay }), + }) + }, + + restartStory() { + wx.showModal({ + title: '从第一回重新开始?', + content: '关卡进度会清空;已经收藏的“我的桂香岁月”和大字设置会保留。', + confirmText: '重新开始', + cancelText: '先不清空', + confirmColor: '#9f3028', + success(result) { + if (!result || !result.confirm) return + if (!resetStoryProgress()) { + wx.showToast({ + title: '进度暂时没有清空,请稍后再试', + icon: 'none', + }) + return + } + wx.redirectTo({ url: chapterRoute(1) }) + }, + }) + }, + + goBack() { + wx.reLaunch({ url: '/pages/home/home' }) + }, + + onShareAppMessage() { + return { + title: '唐侦探:翻开十五回桂香故事', + path: '/pages/catalog/catalog', + } + }, + + onShareTimeline() { + return { + title: '唐侦探:翻开十五回桂香故事', + query: '', + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/pages/catalog/catalog.json b/TUICallKit-Vue3/native/tang-detective/pages/catalog/catalog.json new file mode 100644 index 0000000..83e7971 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/catalog/catalog.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "十五回目录", + "pageOrientation": "landscape" +} diff --git a/TUICallKit-Vue3/native/tang-detective/pages/catalog/catalog.wxml b/TUICallKit-Vue3/native/tang-detective/pages/catalog/catalog.wxml new file mode 100644 index 0000000..2aafb68 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/catalog/catalog.wxml @@ -0,0 +1,63 @@ + + + + + 桂香里的第十五桌 + 第一季 · 十五回目录 + 十五回目录 + + + + + + + + + + {{item.number < 10 ? '0' + item.number : item.number}} + + + + {{item.year}} + {{item.title}} + + + {{item.readingLabel}} + + + 已看见 {{item.eventCount}} 处线索 + + + + + + + 十五回故事,都在这本画册里。 + + + + {{catalogListEnded ? '已经翻到最后一回' : '向下翻,还有更多故事'}} + + + diff --git a/TUICallKit-Vue3/native/tang-detective/pages/catalog/catalog.wxss b/TUICallKit-Vue3/native/tang-detective/pages/catalog/catalog.wxss new file mode 100644 index 0000000..70eb6fe --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/catalog/catalog.wxss @@ -0,0 +1,364 @@ +.catalog-shell { + display: flex; + height: 100vh; + gap: 12px; + flex-direction: column; + overflow: hidden; +} + +.catalog-head { + display: grid; + flex: 0 0 auto; + min-height: 64px; + grid-template-columns: 132px minmax(0, 1fr) 132px; + align-items: center; + gap: 14px; + padding: 8px 112px 8px 14px; +} + +.restart-story-button { + width: 100%; + min-width: 0; + min-height: 48px; + padding-right: 8px; + padding-left: 8px; + color: #7b2c25; + border-color: #a85d4e; +} + +.catalog-heading-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.catalog-head > .quiet-button { + width: 100%; + min-width: 0; + min-height: 48px; + padding-right: 8px; + padding-left: 8px; +} + +.catalog-title { + overflow: hidden; + font-size: 25px; + font-weight: 850; + letter-spacing: 2px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.catalog-title-short { + display: none; +} + +.catalog-heading-copy .eyebrow { + overflow: hidden; + font-size: 16px; + letter-spacing: 1px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chapter-scroll { + min-height: 0; + flex: 1; +} + +.list-scroll-guide { + display: flex; + min-height: 40px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + gap: 10px; + color: #f4e5bd; + background: rgba(58, 40, 28, 0.94); + border: 1px solid rgba(222, 194, 139, 0.55); + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 750; +} + +.scroll-guide-mark { + color: #f0c86b; + font-size: 24px; + line-height: 1; +} + +.chapter-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + padding: 0 2px 22px; +} + +.chapter-card { + position: relative; + display: grid; + width: 100%; + min-height: 112px; + overflow: hidden; + grid-template-columns: 68px minmax(0, 1fr) 24px; + align-items: center; + gap: 14px; + padding: 12px 16px 12px 12px; + text-align: left; + border-radius: 8px; +} + +.chapter-card.completed { + border-color: #50745f; +} + +.chapter-card.current { + border: 3px solid #a23a2d; + box-shadow: 0 10px 25px rgba(94, 32, 24, 0.24); +} + +.chapter-card-hover { + transform: translateY(2px); + opacity: 0.88; +} + +.chapter-number { + display: flex; + height: 88px; + align-items: center; + justify-content: center; + flex-direction: column; + color: #f4e4be; + background: #432b1e; + border-left: 7px solid var(--cinnabar); + font-size: 16px; +} + +.chapter-number .number { + font-family: Georgia, serif; + font-size: 32px; + font-weight: 800; + line-height: 1.1; +} + +.chapter-card-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.year { + color: #9a332b; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 700; +} + +.chapter-name { + display: -webkit-box; + margin-top: 4px; + overflow: hidden; + font-size: 22px; + font-weight: 800; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.chapter-badges { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 15px; +} + +.chapter-badge { + padding: 5px 9px; + border-radius: 999px; +} + +.done-badge { + color: #eff7ef; + background: #37624f; +} + +.current-badge { + color: #fff1cf; + background: #943128; +} + +.read-badge { + color: #725c42; + background: #e5d3a9; +} + +.chapter-progress { + color: #6e5942; + font-size: 15px; + font-weight: 700; +} + +.open-arrow { + color: #8e392f; + font-size: 44px; +} + +.scroll-ending { + display: block; + padding: 4px 0 18px; + color: #d9c69e; + font-size: 17px; + text-align: center; +} + +.font-xlarge .catalog-title { + font-size: 28px; +} + +.font-xlarge .catalog-heading-copy .eyebrow { + font-size: 18px; +} + +.font-xlarge .chapter-card { + min-height: 126px; +} + +.font-xlarge .chapter-number { + height: 100px; + font-size: 18px; +} + +.font-xlarge .chapter-number .number { + font-size: 36px; +} + +.font-xlarge .year { + font-size: 19px; +} + +.font-xlarge .chapter-name { + font-size: 25px; +} + +.font-xlarge .chapter-badges, +.font-xlarge .chapter-progress { + font-size: 17px; +} + +.font-xlarge .scroll-ending { + font-size: 19px; +} + +.font-xlarge .list-scroll-guide { + font-size: 19px; +} + +@media (max-width: 730px), (max-height: 400px) { + .catalog-shell { + gap: 8px; + } + + .catalog-head { + min-height: 58px; + grid-template-columns: 96px minmax(0, 1fr) 116px; + gap: 9px; + padding: 6px 108px 6px 8px; + } + + .catalog-title { + font-size: 21px; + } + + .catalog-heading-copy .eyebrow { + display: none; + } + + .catalog-title-full { + display: none; + } + + .catalog-title-short { + display: block; + } + + .chapter-grid { + gap: 9px; + padding-bottom: 16px; + } + + .chapter-card { + min-height: 102px; + grid-template-columns: 58px minmax(0, 1fr) 20px; + gap: 10px; + padding: 9px 10px 9px 8px; + } + + .chapter-number { + height: 80px; + font-size: 14px; + } + + .chapter-number .number { + font-size: 27px; + } + + .year { + font-size: 15px; + } + + .chapter-name { + font-size: 19px; + } + + .chapter-badges, + .chapter-progress { + font-size: 13px; + } + + .open-arrow { + font-size: 36px; + } + + .list-scroll-guide { + min-height: 36px; + font-size: 16px; + } + + .scroll-guide-mark { + font-size: 21px; + } + + .font-xlarge .catalog-title { + font-size: 24px; + } + + .font-xlarge .chapter-card { + min-height: 116px; + } + + .font-xlarge .chapter-number { + height: 94px; + font-size: 16px; + } + + .font-xlarge .chapter-number .number { + font-size: 31px; + } + + .font-xlarge .year { + font-size: 17px; + } + + .font-xlarge .chapter-name { + font-size: 22px; + } + + .font-xlarge .chapter-badges, + .font-xlarge .chapter-progress { + font-size: 15px; + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/pages/home/home.js b/TUICallKit-Vue3/native/tang-detective/pages/home/home.js new file mode 100644 index 0000000..442a655 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/home/home.js @@ -0,0 +1,121 @@ +const memoryCards = require('../../data/memoryCards') +const { getProgress } = require('../../utils/storage') +const { getCollectedMemories } = require('../../utils/memoryCollection') +const { chapterRoute } = require('../../utils/chapterRoute') +const { + getHomeLayoutMetrics, + getHomeLayoutStyle, + hasReadingProgress, +} = require('./homeLayout') + +Page({ + data: { + coverOpened: false, + completedCount: 0, + lastChapter: 1, + hasReadingProgress: false, + memoryCount: 0, + homeLayoutStyle: [ + 'height:390px', + '--home-top-inset:8px', + '--home-right-inset:10px', + '--home-bottom-inset:7px', + '--home-left-inset:10px', + ].join(';'), + }, + + onLoad() { + this.applyHomeLayout() + }, + + onShow() { + const progress = getProgress() + this.setData({ + completedCount: progress.completedChapters.length, + lastChapter: progress.lastChapter || 1, + hasReadingProgress: hasReadingProgress(progress), + memoryCount: getCollectedMemories(progress, memoryCards).length, + }) + this.applyHomeLayout() + }, + + onResize(resizeInfo) { + this.applyHomeLayout(resizeInfo) + }, + + applyHomeLayout(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + + const resizeSize = resizeInfo && resizeInfo.size + ? resizeInfo.size + : resizeInfo + if (resizeSize) { + windowInfo = { + ...windowInfo, + windowWidth: resizeSize.windowWidth || windowInfo.windowWidth, + windowHeight: resizeSize.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getHomeLayoutMetrics(windowInfo, menuRect) + this.setData({ + homeLayoutStyle: getHomeLayoutStyle(metrics), + }) + }, + + startGame() { + const chapter = this.data.lastChapter || 1 + wx.navigateTo({ + url: chapterRoute(chapter), + }) + }, + + revealCover() { + if (this.data.coverOpened) return + this.setData({ coverOpened: true }) + }, + + keepCoverOpen() {}, + + openCatalog() { + wx.navigateTo({ url: '/pages/catalog/catalog' }) + }, + + openCast() { + wx.navigateTo({ url: '/pages/cast/cast' }) + }, + + openMemories() { + wx.navigateTo({ url: '/pages/memories/memories' }) + }, + + onShareAppMessage() { + return { + title: '唐侦探:在一桌饭里,看见被忽略的人', + path: '/pages/home/home', + } + }, + + onShareTimeline() { + return { + title: '唐侦探:在一桌饭里,看见被忽略的人', + query: '', + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/pages/home/home.json b/TUICallKit-Vue3/native/tang-detective/pages/home/home.json new file mode 100644 index 0000000..5e7aafd --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/home/home.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape" +} diff --git a/TUICallKit-Vue3/native/tang-detective/pages/home/home.wxml b/TUICallKit-Vue3/native/tang-detective/pages/home/home.wxml new file mode 100644 index 0000000..f0b8388 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/home/home.wxml @@ -0,0 +1,65 @@ + + + + + + 甄养堂 · 中国健康连环画 + + 第一季 + 唐侦探 + 桂香里的第十五桌 + + + + 翻开瞧瞧 + + + + + + + + 甄养堂 + 一本能看、能点、能带回家聊的中国健康连环画 + + + + 桂香里的第十五桌 + 第一季 · 一桌饭里的三代人 + “一桌饭,不应该只有坐下的人,还应该有被看见的人。” + + + + + + + + + + 先看画、听故事;翻到背面,再聊聊这回事。 + + + + 这里聊的是日常生活,不替代诊断、处方或个体化治疗建议。 + diff --git a/TUICallKit-Vue3/native/tang-detective/pages/home/home.wxss b/TUICallKit-Vue3/native/tang-detective/pages/home/home.wxss new file mode 100644 index 0000000..8d649fd --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/home/home.wxss @@ -0,0 +1,678 @@ +.home-shell { + --home-top-inset: calc(8px + constant(safe-area-inset-top)); + --home-right-inset: calc(10px + constant(safe-area-inset-right)); + --home-bottom-inset: calc(7px + constant(safe-area-inset-bottom)); + --home-left-inset: calc(10px + constant(safe-area-inset-left)); + --home-top-inset: calc(8px + env(safe-area-inset-top)); + --home-right-inset: calc(10px + env(safe-area-inset-right)); + --home-bottom-inset: calc(7px + env(safe-area-inset-bottom)); + --home-left-inset: calc(10px + env(safe-area-inset-left)); + display: flex; + height: 100vh; + min-height: 0; + padding: var(--home-top-inset) var(--home-right-inset) + var(--home-bottom-inset) var(--home-left-inset); + gap: 6px; + overflow: hidden; + flex-direction: column; +} + +.home-comic-book { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #33251b; + background: #1d130f; + border: 3px solid #9e845e; + border-radius: 10px; + box-shadow: 0 15px 34px rgba(0, 0, 0, 0.36); + grid-template-columns: minmax(0, 1fr); +} + +.home-comic-book.is-open { + grid-template-columns: minmax(0, 1.45fr) minmax(280px, 1fr); +} + +.home-cover-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #241811; +} + +.home-comic-book.is-open .home-cover-art { + border-right: 7px solid #6f2b23; + box-shadow: 10px 0 24px rgba(32, 19, 12, 0.34); +} + +.home-cover-image, +.home-cover-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.home-cover-shade { + pointer-events: none; + background: + linear-gradient(90deg, rgba(29, 18, 12, 0.68), transparent 24%, transparent 72%, rgba(29, 18, 12, 0.2)), + linear-gradient(0deg, rgba(31, 18, 12, 0.82), transparent 53%); + box-shadow: inset 0 0 70px rgba(30, 16, 9, 0.3); +} + +.home-cover-spine { + position: absolute; + z-index: 3; + top: 0; + bottom: 0; + left: 0; + display: flex; + width: 46px; + align-items: center; + justify-content: center; + color: #f0d8a7; + background: rgba(74, 35, 26, 0.94); + border-right: 2px solid #c59a58; + font-family: "Songti SC", "STSong", serif; + font-size: 15px; + font-weight: 850; + letter-spacing: 3px; + writing-mode: vertical-rl; +} + +.home-cover-title-block { + position: absolute; + z-index: 3; + left: 72px; + bottom: 54px; + display: flex; + max-width: 64%; + padding: 13px 19px 15px; + color: #f7e7c4; + background: rgba(43, 27, 18, 0.86); + border-left: 7px solid #a53a30; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.home-cover-series { + color: #e4c277; + font-size: 16px; + font-weight: 800; + letter-spacing: 4px; +} + +.home-cover-title { + margin-top: 3px; + font-family: "Songti SC", "STSong", serif; + font-size: 43px; + font-weight: 900; + letter-spacing: 8px; + line-height: 1.05; +} + +.home-cover-subtitle { + margin-top: 6px; + color: #f1d18c; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.2; +} + +.home-cover-touch { + position: absolute; + z-index: 4; + right: 24px; + bottom: 22px; + display: flex; + min-height: 50px; + align-items: center; + gap: 9px; + padding: 8px 15px; + color: #fff0ca; + background: rgba(54, 34, 23, 0.9); + border: 2px solid #d7b36d; + border-radius: 999px; + font-size: 18px; + font-weight: 850; + box-shadow: 0 7px 18px rgba(0, 0, 0, 0.28); +} + +.home-cover-touch-mark { + display: flex; + width: 31px; + height: 31px; + align-items: center; + justify-content: center; + color: #6c271f; + background: #f0d28c; + border-radius: 50%; + font-size: 28px; + line-height: 1; +} + +.home-flyleaf { + display: flex; + box-sizing: border-box; + min-width: 0; + min-height: 0; + justify-content: center; + overflow: hidden; + padding: 16px 20px; + background: + repeating-linear-gradient(0deg, rgba(92, 61, 35, 0.03) 0, rgba(92, 61, 35, 0.03) 1px, transparent 1px, transparent 5px), + #f2e4bf; + flex-direction: column; +} + +.home-flyleaf-title { + display: block; + margin-top: 9px; + color: #8d2d26; + font-family: "Songti SC", "STSong", serif; + font-size: 29px; + font-weight: 900; + line-height: 1.18; +} + +.home-flyleaf .home-quote { + position: static; + display: block; + margin-top: 10px; + color: #4e3a2b; + font-size: 18px; + font-weight: 750; + line-height: 1.45; +} + +.home-flyleaf .home-primary { + margin-top: 12px; +} + +.home-flyleaf .home-tabs { + width: 100%; + min-width: 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.home-flyleaf .home-tab { + box-sizing: border-box; + width: 100%; + min-width: 0; + overflow: hidden; + padding: 5px 7px; + font-size: 16px; + white-space: nowrap; +} + +.home-comic-book button::after { + border: 0; +} + +.home-book { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(285px, 2fr); + border: 2px solid #9e845e; + border-radius: 9px; +} + +.home-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid var(--cinnabar); +} + +.home-art-image, +.home-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.home-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(35, 23, 16, 0.82), transparent 48%); +} + +.home-memory-ribbon { + position: absolute; + z-index: 3; + top: 12px; + left: 12px; + display: flex; + min-height: 48px; + align-items: center; + gap: 7px; + padding: 6px 10px 6px 7px; + color: #4a3225; + background: rgba(241, 221, 178, 0.96); + border: 2px solid #9e754a; + border-radius: 5px; + box-shadow: 0 5px 14px rgba(25, 15, 8, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + white-space: nowrap; +} + +.home-memory-ribbon-mark { + display: flex; + width: 28px; + height: 35px; + align-items: center; + justify-content: center; + color: #ffe9bb; + background: #963128; + clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 78%, 0 100%); + font-size: 14px; + font-weight: 900; +} + +.home-quote { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + color: #f5e5c0; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 20px; + font-weight: 800; + line-height: 1.5; +} + +.home-copy { + display: flex; + box-sizing: border-box; + min-width: 0; + min-height: 0; + justify-content: center; + overflow-x: hidden; + overflow-y: auto; + padding: 18px 24px; + flex-direction: column; +} + +.home-brand { + display: flex; + align-items: center; + gap: 10px; +} + +.home-brand .seal { + width: 48px; + height: 48px; + flex: none; + font-size: 27px; +} + +.home-brand-copy { + display: flex; + color: #7f2a23; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + line-height: 1.35; +} + +.home-brand-kind { + color: #765f45; + font-size: 16px; + font-weight: 700; +} + +.home-title { + display: block; + margin-top: 10px; + font-size: 45px; + font-weight: 900; + letter-spacing: 7px; + line-height: 1.05; +} + +.home-subtitle { + display: block; + margin-top: 4px; + color: #8d2d26; + font-size: 30px; + font-weight: 900; + line-height: 1.2; +} + +.home-season { + display: block; + margin-top: 5px; + color: #6f5942; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.home-primary { + display: flex; + width: 100%; + min-height: 64px; + align-items: center; + justify-content: center; + margin-top: 16px; + padding: 8px 14px; + color: #fff0cb; + background: var(--cinnabar); + border: 3px solid #74231d; + border-radius: 8px; + box-shadow: 0 8px 18px rgba(111, 32, 27, 0.24); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 900; +} + +.home-tabs { + display: grid; + gap: 9px; + margin-top: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.home-tab { + position: relative; + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 6px 10px; + color: #4b3829; + background: #ead9b2; + border: 2px solid #997b54; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.home-memory-count { + display: flex; + min-width: 30px; + height: 26px; + align-items: center; + justify-content: center; + padding: 0 6px; + color: #f9edce; + background: #704b35; + border-radius: 999px; + font-size: 13px; +} + +.home-tagline { + display: block; + margin-top: 10px; + color: #594431; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + line-height: 1.48; +} + +.home-disclaimer { + display: block; + flex: none; + color: #d7c49b; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + line-height: 1.35; + text-align: center; +} + +@media (max-height: 620px) { + .home-copy { + justify-content: flex-start; + overflow-y: hidden; + padding: 12px 18px; + } + + .home-brand .seal { + width: 42px; + height: 42px; + font-size: 24px; + } + + .home-brand-copy { + font-size: 17px; + } + + .home-brand-kind { + font-size: 15px; + } + + .home-title { + margin-top: 7px; + font-size: 39px; + } + + .home-subtitle { + font-size: 27px; + } + + .home-season { + font-size: 17px; + } + + .home-primary { + min-height: 60px; + margin-top: 11px; + font-size: 20px; + } + + .home-tabs { + margin-top: 8px; + } + + .home-tab { + min-height: 48px; + font-size: 17px; + } + + .home-tagline { + display: block; + margin-top: 6px; + font-size: 16px; + line-height: 1.35; + } + + .home-disclaimer { + font-size: 16px; + } +} + +@media (max-width: 730px) { + .home-brand-kind { + display: none; + } + + .home-brand { + max-width: calc(100% - 104px); + } + + .home-title { + font-size: 39px; + letter-spacing: 5px; + } + + .home-subtitle { + font-size: 26px; + } + + .home-tagline { + display: block; + margin-top: 6px; + font-size: 16px; + line-height: 1.35; + } + + .home-memory-ribbon { + top: 9px; + left: 9px; + min-height: 48px; + padding: 5px 8px 5px 6px; + font-size: 16px; + } +} + +@media (max-height: 430px) { + .home-comic-book.is-open { + grid-template-columns: minmax(0, 1.3fr) minmax(292px, 1fr); + } + + .home-cover-title-block { + left: 62px; + bottom: 34px; + padding: 9px 13px 10px; + } + + .home-cover-series { + font-size: 13px; + } + + .home-cover-title { + font-size: 34px; + letter-spacing: 6px; + } + + .home-cover-subtitle { + font-size: 20px; + } + + .home-cover-touch { + right: 16px; + bottom: 13px; + min-height: 48px; + font-size: 16px; + } + + .home-flyleaf { + padding: 8px 12px; + } + + .home-flyleaf .home-brand .seal { + width: 34px; + height: 34px; + font-size: 20px; + } + + .home-flyleaf .home-brand-copy { + font-size: 14px; + } + + .home-flyleaf-title { + margin-top: 5px; + font-size: 23px; + } + + .home-flyleaf .home-season { + font-size: 14px; + } + + .home-flyleaf .home-quote { + margin-top: 5px; + font-size: 15px; + line-height: 1.3; + } + + .home-flyleaf .home-primary { + min-height: 50px; + margin-top: 6px; + font-size: 18px; + } + + .home-flyleaf .home-tabs { + gap: 5px; + margin-top: 5px; + } + + .home-flyleaf .home-tab { + min-height: 48px; + padding: 4px; + font-size: 14px; + } + + .home-flyleaf .home-tagline { + margin-top: 4px; + font-size: 14px; + } + + .home-copy { + justify-content: flex-start; + overflow-y: hidden; + padding: 8px 14px; + } + + .home-brand .seal { + width: 36px; + height: 36px; + font-size: 22px; + } + + .home-brand-copy { + font-size: 15px; + } + + .home-brand-kind { + font-size: 13px; + } + + .home-title { + margin-top: 4px; + font-size: 34px; + letter-spacing: 4px; + } + + .home-subtitle { + margin-top: 2px; + font-size: 23px; + } + + .home-season { + margin-top: 3px; + font-size: 15px; + } + + .home-primary { + min-height: 60px; + margin-top: 7px; + padding: 6px 10px; + font-size: 19px; + } + + .home-tabs { + margin-top: 5px; + } + + .home-tagline { + display: block; + margin-top: 4px; + font-size: 15px; + line-height: 1.2; + } + + .home-disclaimer { + font-size: 14px; + line-height: 1.2; + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/pages/home/homeLayout.js b/TUICallKit-Vue3/native/tang-detective/pages/home/homeLayout.js new file mode 100644 index 0000000..ee2713c --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/home/homeLayout.js @@ -0,0 +1,122 @@ +function finiteNumber(value, fallback = 0) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)) +} + +/** + * 首页使用自定义导航栏,横屏时既要避开左右刘海与底部 Home + * Indicator,也要为微信右上角胶囊留下真实空间。CSS 的 env() + * 在部分微信横屏机型上只会返回系统安全区,不包含胶囊,因此这里 + * 以运行时尺寸为准,CSS env() 只作为脚本尚未执行时的兜底。 + */ +function getHomeLayoutMetrics(windowInfo = {}, menuRect = {}) { + const windowWidth = Math.max(320, finiteNumber(windowInfo.windowWidth, 844)) + const windowHeight = Math.max(240, finiteNumber(windowInfo.windowHeight, 390)) + const safeArea = windowInfo.safeArea || {} + + const safeLeft = clamp( + finiteNumber(safeArea.left, 0), + 0, + windowWidth / 3, + ) + const safeRightEdge = clamp( + finiteNumber(safeArea.right, windowWidth), + windowWidth * 2 / 3, + windowWidth, + ) + const safeRight = clamp( + windowWidth - safeRightEdge, + 0, + windowWidth / 3, + ) + const safeTop = Math.max( + 0, + finiteNumber(windowInfo.statusBarHeight, 0), + finiteNumber(safeArea.top, 0), + ) + const safeBottomEdge = clamp( + finiteNumber(safeArea.bottom, windowHeight), + windowHeight * 2 / 3, + windowHeight, + ) + const safeBottom = clamp( + windowHeight - safeBottomEdge, + 0, + windowHeight / 3, + ) + + const menuLeft = finiteNumber(menuRect.left, windowWidth) + const menuBottom = finiteNumber(menuRect.bottom, 0) + const hasMenuRect = ( + menuLeft > windowWidth / 2 + && menuLeft < windowWidth + && menuBottom > 0 + ) + const compactHeight = windowHeight <= 620 + const horizontalPageGap = compactHeight ? 10 : 14 + const capsuleGap = compactHeight ? 8 : 10 + + const leftInset = Math.ceil(safeLeft + horizontalPageGap) + const rightInset = Math.ceil(Math.max( + safeRight + horizontalPageGap, + hasMenuRect + ? windowWidth - menuLeft + capsuleGap + : safeRight + horizontalPageGap, + )) + const topInset = Math.ceil(safeTop + (compactHeight ? 8 : 12)) + const bottomInset = Math.ceil(safeBottom + (compactHeight ? 7 : 12)) + + return { + windowWidth, + windowHeight, + compactHeight, + safeLeft, + safeRight, + safeTop, + safeBottom, + leftInset, + rightInset, + topInset, + bottomInset, + hasMenuRect, + } +} + +function getHomeLayoutStyle(metrics = {}) { + return [ + `height:${Math.max(240, finiteNumber(metrics.windowHeight, 390))}px`, + `--home-top-inset:${Math.max(0, finiteNumber(metrics.topInset, 8))}px`, + `--home-right-inset:${Math.max(0, finiteNumber(metrics.rightInset, 10))}px`, + `--home-bottom-inset:${Math.max(0, finiteNumber(metrics.bottomInset, 7))}px`, + `--home-left-inset:${Math.max(0, finiteNumber(metrics.leftInset, 10))}px`, + ].join(';') +} + +function hasReadingProgress(progress = {}) { + const completedChapters = Array.isArray(progress.completedChapters) + ? progress.completedChapters + : [] + const completedHotspots = progress.completedHotspots + && typeof progress.completedHotspots === 'object' + ? progress.completedHotspots + : {} + const hasCompletedHotspot = Object.values(completedHotspots).some( + (ids) => Array.isArray(ids) && ids.length > 0, + ) + + return ( + completedChapters.length > 0 + || finiteNumber(progress.lastChapter, 1) > 1 + || hasCompletedHotspot + ) +} + +module.exports = { + getHomeLayoutMetrics, + getHomeLayoutStyle, + hasReadingProgress, +} diff --git a/TUICallKit-Vue3/native/tang-detective/pages/memories/memories.js b/TUICallKit-Vue3/native/tang-detective/pages/memories/memories.js new file mode 100644 index 0000000..452c837 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/memories/memories.js @@ -0,0 +1,77 @@ +const memoryCards = require('../../data/memoryCards') +const { + getProgress, + getSettings, + saveSettings, +} = require('../../utils/storage') +const { + getCollectedMemories, +} = require('../../utils/memoryCollection') +const { chapterRoute } = require('../../utils/chapterRoute') + +function twoDigits(value) { + return String(value).padStart(2, '0') +} + +Page({ + data: { + memories: [], + memoryCount: 0, + fontScale: 'large', + }, + + onShow() { + const progress = getProgress() + const settings = getSettings() + const memories = getCollectedMemories(progress, memoryCards).map( + (memory, index) => ({ + ...memory, + chapterLabel: `第${twoDigits(memory.chapterNumber)}回`, + pageLabel: `第${index + 1}页`, + characterInitial: memory.characterName.slice(0, 1), + }), + ) + this.setData({ + memories, + memoryCount: memories.length, + fontScale: settings.fontScale === 'xlarge' ? 'xlarge' : 'large', + }) + }, + + goBack() { + wx.reLaunch({ url: '/pages/home/home' }) + }, + + openCatalog() { + wx.navigateTo({ url: '/pages/catalog/catalog' }) + }, + + openChapter(event) { + const chapter = Number(event.currentTarget.dataset.chapter) || 1 + wx.navigateTo({ + url: chapterRoute(chapter), + }) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ fontScale }) + }, + + onShareAppMessage() { + return { + title: '我的桂香岁月:饭桌边,也有值得带回家说的话', + path: '/pages/home/home', + } + }, + + onShareTimeline() { + return { + title: '我的桂香岁月:饭桌边,也有值得带回家说的话', + query: '', + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/pages/memories/memories.json b/TUICallKit-Vue3/native/tang-detective/pages/memories/memories.json new file mode 100644 index 0000000..b8be3f9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/memories/memories.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/pages/memories/memories.wxml b/TUICallKit-Vue3/native/tang-detective/pages/memories/memories.wxml new file mode 100644 index 0000000..ea00094 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/memories/memories.wxml @@ -0,0 +1,91 @@ + + + + + + 我的桂香岁月 + + {{memoryCount > 0 ? '收下的故事,慢慢翻、慢慢聊' : '一本等你慢慢装满的故事册'}} + + + + + + + + + + 桂香 + + 这本册子,还等着第一段回忆 + 每读完一回,把最后那一页“收进画册”,这里就会多一段人物、一件旧物和一句带回家聊的话。 + + + + + + + + + + {{item.pageLabel}} + {{item.characterInitial}} + {{item.characterName}} + {{item.eraLine}} + + 这一回留下的物件 + {{item.eraObject || '桂香饭桌边的一件旧物'}} + + + + + {{item.chapterLabel}} · {{item.chapterTitle}} + “{{item.tableEcho}}” + + 今天可以这样做 + {{item.lifeAction}} + + + 带回家聊一聊 + “{{item.familyLine}}” + + + + + + 往后的页,还在饭桌边等你 + 每收下一页,不是为了攒数字,只为记住一个被看见的人。 + + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/pages/memories/memories.wxss b/TUICallKit-Vue3/native/tang-detective/pages/memories/memories.wxss new file mode 100644 index 0000000..bc67fe6 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/memories/memories.wxss @@ -0,0 +1,568 @@ +/* Personal storybook: horizontal pages with elder-friendly reading controls. */ +.memories-shell { + height: 100vh; + min-height: 0; + overflow: hidden; +} + +.memories-book { + display: flex; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + border-radius: 9px; + flex-direction: column; +} + +.memories-head { + position: relative; + display: flex; + min-height: 66px; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + padding: 8px 10px 8px 12px; + border-bottom: 3px solid #9b332a; +} + +.memory-back, +.memory-font, +.empty-action, +.memory-reread, +.memory-end-action { + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 6px 13px; + border: 2px solid #997a54; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-back, +.memory-font { + color: #52392a; + background: #ead8af; +} + +.memories-heading { + position: absolute; + z-index: 1; + top: 8px; + right: 246px; + left: 134px; + display: flex; + min-width: 0; + align-items: center; + pointer-events: none; + flex-direction: column; +} + +.memories-title { + overflow: hidden; + font-size: 29px; + font-weight: 900; + letter-spacing: 3px; + line-height: 1.16; + text-overflow: ellipsis; + white-space: nowrap; +} + +.memories-subtitle { + overflow: hidden; + color: #725b43; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.capsule-safe-space { + width: 102px; + height: 48px; + flex: 0 0 102px; +} + +.memories-head-actions { + position: absolute; + z-index: 2; + top: 8px; + right: 10px; + display: flex; + align-items: center; + gap: 10px; +} + +.memory-font { + min-width: 112px; + white-space: nowrap; +} + +.memory-back-short, +.memory-font-short { + display: none; +} + +.memory-back { + position: absolute; + z-index: 2; + top: 8px; + left: 12px; + width: 112px; + min-width: 112px; +} + +.empty-memory { + display: grid; + width: calc(100% - 48px); + max-width: 680px; + min-height: 230px; + align-self: center; + grid-template-columns: 132px minmax(0, 1fr); + align-items: stretch; + margin: auto; + overflow: hidden; + background: #f8ecc9; + border: 3px double #97784f; + border-radius: 6px; + box-shadow: 0 12px 28px rgba(67, 40, 23, 0.18); +} + +.empty-bookmark { + display: flex; + align-items: center; + justify-content: center; + color: #f8e7bb; + background: #913128; + font-size: 30px; + font-weight: 900; + letter-spacing: 7px; + writing-mode: vertical-rl; +} + +.empty-copy { + display: flex; + justify-content: center; + padding: 22px 28px; + flex-direction: column; +} + +.empty-title { + font-size: 27px; + font-weight: 900; +} + +.empty-text { + margin-top: 9px; + color: #604b36; + font-size: 18px; + font-weight: 650; + line-height: 1.55; +} + +.empty-action { + width: 190px; + margin-top: 14px; + color: #fff0cc; + background: #943128; + border-color: #74231d; +} + +.memory-scroll { + width: 100%; + min-height: 0; + flex: 1; + padding: 12px 0 10px; +} + +.memory-pages { + display: inline-flex; + min-width: 100%; + height: 100%; + align-items: stretch; + gap: 16px; + padding: 0 18px; +} + +.memory-sheet { + position: relative; + display: grid; + width: 710px; + height: 100%; + min-height: 0; + flex: none; + overflow: hidden; + grid-template-columns: 225px minmax(0, 1fr); + background: + repeating-linear-gradient(0deg, rgba(84, 55, 31, 0.025) 0, rgba(84, 55, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f8edcc; + border: 3px double #91724b; + border-radius: 6px; + box-shadow: 0 10px 24px rgba(58, 35, 21, 0.2); + scroll-snap-align: center; +} + +.memory-sheet-spine { + position: absolute; + z-index: 2; + top: 0; + bottom: 0; + left: 219px; + width: 7px; + pointer-events: none; + background: linear-gradient(90deg, rgba(81, 50, 27, 0.1), rgba(255, 255, 255, 0.58), rgba(81, 50, 27, 0.12)); +} + +.memory-left { + position: relative; + display: flex; + min-width: 0; + align-items: center; + padding: 17px 20px; + background: #263a3e; + flex-direction: column; + color: #f5e5bd; +} + +.memory-page-number { + align-self: flex-start; + color: #d6ba7e; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 750; +} + +.memory-person-seal { + display: flex; + width: 74px; + height: 74px; + align-items: center; + justify-content: center; + margin-top: 4px; + color: #8d2d25; + background: #efe0b8; + border: 5px double #ad4d3a; + border-radius: 50%; + font-size: 37px; + font-weight: 900; +} + +.memory-person { + margin-top: 5px; + font-size: 25px; + font-weight: 900; +} + +.memory-era { + margin-top: 4px; + color: #e3cf9f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 650; + line-height: 1.4; + text-align: center; +} + +.memory-object { + display: flex; + width: 100%; + margin-top: auto; + padding: 8px 10px; + color: #f5e5bd; + background: rgba(0, 0, 0, 0.18); + border-left: 4px solid #caa65e; + flex-direction: column; + font-size: 17px; + font-weight: 750; + line-height: 1.4; +} + +.memory-right { + min-width: 0; + height: 100%; + padding: 14px 22px 16px 25px; +} + +.memory-chapter { + display: block; + color: #8d2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; +} + +.memory-echo { + display: block; + margin-top: 7px; + padding-bottom: 7px; + font-size: 23px; + font-weight: 900; + line-height: 1.45; + border-bottom: 1px solid #c9af7f; +} + +.memory-note { + display: flex; + margin-top: 8px; + color: #4f3c2b; + font-size: 17px; + font-weight: 700; + line-height: 1.42; + flex-direction: column; +} + +.memory-label { + display: block; + margin-bottom: 2px; + color: #a03b31; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 15px; + font-weight: 850; +} + +.family-note { + padding-left: 10px; + border-left: 4px solid #35634f; +} + +.memory-reread { + width: 156px; + margin-top: 10px; + color: #fff0cc; + background: #8d3028; + border-color: #70231e; +} + +.memory-end-card { + display: flex; + width: 310px; + height: 100%; + min-height: 0; + flex: none; + justify-content: center; + padding: 24px; + color: #f2e2b8; + background: #3a2a20; + border: 3px double #ad8d5e; + border-radius: 6px; + flex-direction: column; + font-size: 18px; + font-weight: 650; + line-height: 1.55; +} + +.memory-end-title { + margin-bottom: 10px; + font-size: 26px; + font-weight: 900; +} + +.memory-end-action { + width: 170px; + margin-top: 18px; + color: #34251c; + background: #e9d6a8; +} + +.font-xlarge .memory-echo { + font-size: 26px; +} + +.font-xlarge .memory-note, +.font-xlarge .memory-object, +.font-xlarge .memory-end-card { + font-size: 19px; +} + +@media (max-width: 730px), (max-height: 400px) { + .memories-head { + min-height: 58px; + padding: 5px 4px; + } + + .memories-heading { + top: 17px; + right: 158px; + left: 82px; + } + + .memory-back, + .memory-font, + .empty-action, + .memory-reread, + .memory-end-action { + min-height: 48px; + padding-right: 8px; + padding-left: 8px; + font-size: 16px; + } + + .memories-title { + font-size: 18px; + letter-spacing: 0; + } + + .memories-subtitle { + display: none; + } + + .capsule-safe-space { + width: 76px; + flex-basis: 76px; + } + + .memory-font { + width: 72px; + min-width: 72px; + padding-right: 4px; + padding-left: 4px; + } + + .memory-back { + top: 5px; + left: 4px; + width: 74px; + min-width: 0; + padding-right: 4px; + padding-left: 4px; + } + + .memories-head-actions { + top: 5px; + right: 4px; + gap: 4px; + } + + .memory-back-long, + .memory-font-long { + display: none; + } + + .memory-back-short, + .memory-font-short { + display: inline; + } + + .memory-scroll { + padding-top: 8px; + padding-bottom: 8px; + } + + .memory-pages { + gap: 12px; + padding: 0 10px; + } + + .memory-sheet { + width: 565px; + grid-template-columns: 170px minmax(0, 1fr); + } + + .memory-sheet-spine { + left: 164px; + } + + .memory-left { + padding: 10px 12px; + } + + .memory-page-number { + font-size: 14px; + } + + .memory-person-seal { + width: 58px; + height: 58px; + margin-top: 1px; + font-size: 29px; + } + + .memory-person { + margin-top: 2px; + font-size: 21px; + } + + .memory-era { + font-size: 14px; + line-height: 1.3; + } + + .memory-object { + padding: 6px 8px; + font-size: 15px; + } + + .memory-right { + padding: 10px 14px 12px 18px; + } + + .memory-chapter { + font-size: 15px; + } + + .memory-echo { + margin-top: 4px; + padding-bottom: 5px; + font-size: 20px; + line-height: 1.32; + } + + .memory-note { + margin-top: 5px; + font-size: 15.5px; + line-height: 1.32; + } + + .memory-label { + font-size: 14px; + } + + .memory-reread { + width: 138px; + margin-top: 7px; + } + + .font-xlarge .memory-echo { + font-size: 22px; + } + + .font-xlarge .memory-note, + .font-xlarge .memory-object, + .font-xlarge .memory-end-card { + font-size: 17px; + } + + .empty-memory { + width: calc(100% - 24px); + max-width: 590px; + min-height: 220px; + grid-template-columns: 100px minmax(0, 1fr); + } + + .empty-bookmark { + font-size: 25px; + } + + .empty-copy { + padding: 15px 20px; + } + + .empty-title { + font-size: 23px; + } + + .empty-text { + margin-top: 5px; + font-size: 16px; + line-height: 1.42; + } + + .empty-action { + margin-top: 8px; + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/pages/report/report.js b/TUICallKit-Vue3/native/tang-detective/pages/report/report.js new file mode 100644 index 0000000..375179f --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/report/report.js @@ -0,0 +1,85 @@ +const memoryCards = require('../../data/memoryCards') +const { chapterRoute } = require('../../utils/chapterRoute') + +const MEMORY_CARD_ID_PATTERN = /^S\d{2}-C\d{2}-MC\d{2}$/ +const INVALID_CARD_MESSAGE = '这回生活观察暂时没有找到。回到封面,还可以慢慢翻一回。' + +function normalizeCardId(value) { + const raw = String(value || '').trim() + if (!raw || raw.length > 64) return '' + let decoded = raw + try { + decoded = decodeURIComponent(raw) + } catch (error) { + return '' + } + return MEMORY_CARD_ID_PATTERN.test(decoded) ? decoded : '' +} + +function findReportCard(value) { + const cardId = normalizeCardId(value) + if (!cardId) return null + return memoryCards.find((card) => card.cardId === cardId) || null +} + +function twoDigits(value) { + return String(value).padStart(2, '0') +} + +Page({ + data: { + reportCard: null, + chapterLabel: '', + invalidCard: false, + invalidCardMessage: INVALID_CARD_MESSAGE, + }, + + onLoad(options = {}) { + const reportCard = findReportCard(options.cardId) + if (!reportCard) { + this.setData({ + reportCard: null, + chapterLabel: '', + invalidCard: true, + }) + return + } + this.setData({ + reportCard: { ...reportCard }, + chapterLabel: `第${twoDigits(reportCard.chapterNumber)}回`, + invalidCard: false, + }) + }, + + backToChapter() { + const reportCard = this.data.reportCard + if (!reportCard || !reportCard.chapterNumber) { + this.goHome() + return + } + const pages = typeof getCurrentPages === 'function' + ? getCurrentPages() + : [] + const previous = pages[pages.length - 2] + if ( + previous + && String(previous.route || '').endsWith('/pages/chapter/chapter') + && typeof wx.navigateBack === 'function' + ) { + wx.navigateBack({ delta: 1 }) + return + } + wx.navigateTo({ + url: chapterRoute(reportCard.chapterNumber), + }) + }, + + goHome() { + const target = { url: '/pages/home/home' } + if (typeof wx.reLaunch === 'function') { + wx.reLaunch(target) + return + } + wx.redirectTo(target) + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/pages/report/report.json b/TUICallKit-Vue3/native/tang-detective/pages/report/report.json new file mode 100644 index 0000000..b8be3f9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/report/report.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/pages/report/report.wxml b/TUICallKit-Vue3/native/tang-detective/pages/report/report.wxml new file mode 100644 index 0000000..174980a --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/report/report.wxml @@ -0,0 +1,81 @@ + + + + 桂香生活观察 + + {{chapterLabel}} + {{reportCard.chapterTitle}} + + 这回人物 + {{reportCard.characterName}} + + + 这页只留在您的手机里。 + 不会发送答题记录,也不用于诊断。 + + + + + + + 这一回,咱们看见了什么 + 本回生活观察,不是个人健康评估,也不替代诊断和个体化治疗 + + + + + + 本回看见 + {{reportCard.observation}} + + + + + + 家里可以 + {{reportCard.familySupport}} + + + + + + 今天试试 + {{reportCard.todayAction}} + + + + + + + + + + + + + + + 这回观察,暂时没有翻开 + {{invalidCardMessage}} + + + diff --git a/TUICallKit-Vue3/native/tang-detective/pages/report/report.wxss b/TUICallKit-Vue3/native/tang-detective/pages/report/report.wxss new file mode 100644 index 0000000..a87d53d --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/report/report.wxss @@ -0,0 +1,28 @@ +.report-shell{height:100vh;min-height:0;overflow:hidden} +.report-book{display:grid;width:100%;height:100%;overflow:hidden;grid-template-columns:minmax(210px,2fr) minmax(0,5fr);border:2px solid #9e845e;border-radius:9px} +.report-identity{display:flex;min-height:0;align-items:center;padding:15px 18px;overflow:hidden;color:#f5e5bd;background:#263a3e;border-right:5px solid #9b332a;flex-direction:column} +.report-kicker{align-self:flex-start;color:#dfc486;font-size:17px;font-weight:850} +.report-seal,.report-invalid-seal{display:flex;align-items:center;justify-content:center;color:#8d2d25;background:#efe0b8;border:5px double #ad4d3a;border-radius:50%;font-weight:900} +.report-seal{width:66px;height:66px;flex:none;margin:7px 0 4px;font-size:32px} +.report-title{margin-top:3px;font-size:21px;font-weight:900;line-height:1.2;text-align:center} +.report-person-label{color:#d7bd7d;font-size:14px;font-weight:850}.report-person{margin-left:8px;font-size:18px;font-weight:900} +.report-local-note{display:flex;width:100%;margin-top:auto;padding:6px 8px;color:#eadbb5;background:rgba(19,30,32,.38);border:1px solid rgba(223,196,134,.42);border-radius:6px;flex-direction:column;font-size:14px;font-weight:700;line-height:1.3} +.report-main{position:relative;display:grid;min-height:0;padding:46px 17px 12px;gap:8px;grid-template-rows:minmax(0,1fr) auto} +.report-capsule-safe-space{position:absolute;top:6px;right:8px;width:104px;height:36px;pointer-events:none} +.report-scroll{width:100%;height:100%;min-height:0} +.report-heading{display:block;padding-right:108px;color:#493325;font-size:24px;font-weight:900;line-height:1.22} +.report-disclaimer{display:block;margin-top:6px;padding:6px 9px;color:#644a37;background:#eadfbe;border-left:5px solid #8f3028;font-size:15px;font-weight:750;line-height:1.3} +.report-observation-list{display:grid;gap:6px;margin-top:7px} +.report-observation-card{display:grid;align-items:center;gap:9px;padding:6px 9px;background:rgba(255,250,232,.72);border:1px solid rgba(126,86,50,.32);border-radius:7px;grid-template-columns:34px minmax(0,1fr)} +.report-observation-card.is-family{border-left:5px solid #8c6f42}.report-observation-card.is-today{border-left:5px solid #47705c} +.report-observation-index{padding:5px;color:#fff0ce;background:#8f3028;border-radius:50%;font-size:17px;font-weight:900;text-align:center} +.report-observation-copy{display:grid;min-width:0;gap:2px} +.report-observation-label{color:#8d2f27;font-size:15px;font-weight:900} +.report-observation-value{color:#493325;font-size:19px;font-weight:750;line-height:1.32} +.report-actions{display:grid;gap:9px;grid-template-columns:repeat(2,minmax(0,1fr))} +.report-action{display:flex;min-height:52px;align-items:center;justify-content:center;padding:6px 10px;border:3px solid;border-radius:8px;font-size:19px;font-weight:900;line-height:1.2} +.report-action-primary{color:#fff0cb;background:#963128;border-color:#74231d}.report-action-secondary{color:#473326;background:#ead9b2;border-color:#997b54} +.report-invalid{display:flex;width:min(620px,100%);align-items:center;margin:auto;padding:20px;flex-direction:column}.report-invalid-seal{padding:12px;font-size:28px}.report-invalid-title{font-size:22px;font-weight:900} +@media(max-width:700px),(max-height:420px){ +.report-book{grid-template-columns:minmax(180px,2fr) minmax(0,5fr)}.report-identity{padding:7px 10px}.report-kicker{font-size:14px}.report-seal{width:42px;height:42px;margin:2px 0;border-width:3px;font-size:22px}.report-title{font-size:17px}.report-local-note{padding:3px 5px;font-size:12px;line-height:1.15} +.report-main{padding:41px 9px 6px;gap:4px}.report-heading{padding-right:100px;font-size:20px}.report-disclaimer{margin-top:3px;padding:3px 6px;font-size:13px;line-height:1.18}.report-observation-list{gap:3px;margin-top:3px}.report-observation-card{gap:6px;padding:3px 6px;grid-template-columns:28px minmax(0,1fr)}.report-observation-index{font-size:14px}.report-observation-label{font-size:13px}.report-observation-value{font-size:16px;line-height:1.18}.report-actions{gap:5px}.report-action{min-height:48px;padding:4px 6px;font-size:16px}} diff --git a/TUICallKit-Vue3/native/tang-detective/pages/share/share.js b/TUICallKit-Vue3/native/tang-detective/pages/share/share.js new file mode 100644 index 0000000..7b42694 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/share/share.js @@ -0,0 +1,112 @@ +const memoryCards = require('../../data/memoryCards') +const { chapterRoute } = require('../../utils/chapterRoute') + +const MEMORY_CARD_ID_PATTERN = /^S\d{2}-C\d{2}-MC\d{2}$/ +const INVALID_CARD_MESSAGE = '这张桂香记忆卡暂时没有找到。回到封面,还可以慢慢翻一回。' +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '/pages/home/home' + return `/pages/share/share?cardId=${encodeURIComponent(cardId)}&source=family-memory` +} + +function normalizeCardId(value) { + const raw = String(value || '').trim() + if (!raw || raw.length > 64) return '' + let decoded = raw + try { + decoded = decodeURIComponent(raw) + } catch (error) { + return '' + } + return MEMORY_CARD_ID_PATTERN.test(decoded) ? decoded : '' +} + +function findMemoryCard(value) { + const cardId = normalizeCardId(value) + if (!cardId) return null + return memoryCards.find((card) => card.cardId === cardId) || null +} + +function twoDigits(value) { + return String(value).padStart(2, '0') +} + +Page({ + data: { + memoryCard: null, + chapterLabel: '', + characterInitial: '', + invalidCard: false, + invalidCardMessage: INVALID_CARD_MESSAGE, + }, + + onLoad(options = {}) { + const memoryCard = findMemoryCard(options.cardId) + if (!memoryCard) { + this.setData({ + memoryCard: null, + chapterLabel: '', + characterInitial: '', + invalidCard: true, + }) + return + } + this.setData({ + memoryCard: { ...memoryCard }, + chapterLabel: `第${twoDigits(memoryCard.chapterNumber)}回`, + characterInitial: String(memoryCard.characterName || '桂').slice(0, 1), + invalidCard: false, + }) + }, + + openChapter() { + const memoryCard = this.data.memoryCard + if (!memoryCard || !memoryCard.chapterNumber) { + this.goHome() + return + } + wx.navigateTo({ + url: chapterRoute(memoryCard.chapterNumber), + }) + }, + + goHome() { + const target = { url: '/pages/home/home' } + if (typeof wx.reLaunch === 'function') { + wx.reLaunch(target) + return + } + wx.redirectTo(target) + }, + + onShareAppMessage() { + const memoryCard = this.data.memoryCard + if (!memoryCard) { + return { + title: '唐侦探:饭桌边,也有值得带回家说的话', + path: '/pages/home/home', + imageUrl: SHARE_PREVIEW_IMAGE, + } + } + return { + title: `带回家的一句话:${memoryCard.familyLine}`, + path: memoryCardSharePath(memoryCard), + imageUrl: SHARE_PREVIEW_IMAGE, + } + }, + + onShareTimeline() { + const memoryCard = this.data.memoryCard + return memoryCard + ? { + title: `桂香记忆:${memoryCard.tableEcho}`, + query: `cardId=${encodeURIComponent(memoryCard.cardId)}&source=family-memory`, + } + : { + title: '唐侦探:饭桌边,也有值得带回家说的话', + query: '', + } + }, +}) diff --git a/TUICallKit-Vue3/native/tang-detective/pages/share/share.json b/TUICallKit-Vue3/native/tang-detective/pages/share/share.json new file mode 100644 index 0000000..b8be3f9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/share/share.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "disableScroll": true +} diff --git a/TUICallKit-Vue3/native/tang-detective/pages/share/share.wxml b/TUICallKit-Vue3/native/tang-detective/pages/share/share.wxml new file mode 100644 index 0000000..415710a --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/share/share.wxml @@ -0,0 +1,71 @@ + + + + + diff --git a/TUICallKit-Vue3/native/tang-detective/pages/share/share.wxss b/TUICallKit-Vue3/native/tang-detective/pages/share/share.wxss new file mode 100644 index 0000000..bcff450 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/pages/share/share.wxss @@ -0,0 +1,326 @@ +.share-shell { + display: flex; + height: 100vh; + min-height: 0; + overflow: hidden; +} + +.share-card { + position: relative; + display: grid; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(205px, 2fr) minmax(0, 5fr); + border: 2px solid #9e845e; + border-radius: 9px; +} + +.share-identity { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + padding: 18px 22px; + overflow: hidden; + color: #f5e5bd; + background: + repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025) 0, rgba(255, 255, 255, 0.025) 1px, transparent 1px, transparent 6px), + #263a3e; + border-right: 5px solid #9b332a; + flex-direction: column; +} + +.share-kicker, +.share-meta-label, +.share-section-label, +.share-actions, +.share-invalid-copy { + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; +} + +.share-kicker { + align-self: flex-start; + color: #dfc486; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.share-person-seal, +.share-invalid-seal { + display: flex; + align-items: center; + justify-content: center; + color: #8d2d25; + background: #efe0b8; + border: 5px double #ad4d3a; + border-radius: 50%; + font-weight: 900; +} + +.share-person-seal { + width: 78px; + height: 78px; + flex: none; + margin: 9px 0 6px; + font-size: 38px; +} + +.share-meta-block { + display: grid; + width: 100%; + min-width: 0; + align-items: start; + gap: 7px; + margin-top: 5px; + grid-template-columns: 42px minmax(0, 1fr); +} + +.share-meta-label { + color: #d5b974; + font-size: 15px; + font-weight: 850; +} + +.share-person { + font-size: 22px; + font-weight: 900; + line-height: 1.25; +} + +.share-era { + color: #f0deae; + font-size: 17px; + font-weight: 750; + line-height: 1.35; +} + +.share-chapter { + display: -webkit-box; + width: 100%; + margin-top: auto; + overflow: hidden; + color: #f7e9c7; + font-size: 17px; + font-weight: 850; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.share-main { + position: relative; + display: grid; + min-width: 0; + min-height: 0; + padding: 48px 20px 16px; + grid-template-rows: minmax(0, 1fr) auto; + gap: 12px; +} + +.share-capsule-safe-space { + position: absolute; + top: 6px; + right: 8px; + width: 104px; + height: 36px; + pointer-events: none; +} + +.share-copy { + width: 100%; + height: 100%; + min-height: 0; +} + +.share-section { + display: grid; + align-items: start; + gap: 12px; + padding: 11px 14px; + grid-template-columns: 92px minmax(0, 1fr); + border-top: 1px solid rgba(126, 86, 50, 0.24); +} + +.share-section:first-child { + padding-top: 0; + border-top: 0; +} + +.share-section-label { + color: #8d2f27; + font-size: 17px; + font-weight: 900; +} + +.share-quote, +.share-section-copy { + display: block; + font-size: 19px; + font-weight: 750; + line-height: 1.5; +} + +.share-quote { + color: #493325; + font-size: 21px; + font-weight: 850; +} + +.share-family { + background: rgba(167, 114, 54, 0.08); + border-bottom: 1px solid rgba(126, 86, 50, 0.22); +} + +.share-actions { + display: grid; + width: 100%; + min-width: 0; + gap: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.share-action { + display: flex; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + border: 3px solid; + border-radius: 8px; + font-size: 20px; + font-weight: 900; + line-height: 1.2; + justify-self: stretch; +} + +.share-action-primary { + color: #fff0cb; + background: #963128; + border-color: #74231d; + box-shadow: 0 8px 18px rgba(111, 32, 27, 0.2); +} + +.share-action-secondary { + color: #473326; + background: #ead9b2; + border-color: #997b54; +} + +.share-action-forward { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.share-invalid { + display: flex; + width: min(620px, 100%); + min-height: 250px; + align-self: center; + align-items: center; + justify-content: center; + margin: auto; + padding: 24px 34px; + border-radius: 9px; + flex-direction: column; + text-align: center; +} + +.share-invalid-seal { + width: 68px; + height: 68px; + flex: none; + font-size: 31px; +} + +.share-invalid-title { + margin-top: 9px; + color: #783028; + font-size: 26px; + font-weight: 900; +} + +.share-invalid-copy { + margin-top: 7px; + color: #604b36; + font-size: 18px; + font-weight: 700; + line-height: 1.5; +} + +.share-invalid-action { + width: 210px; + margin-top: 15px; +} + +@media (max-width: 730px), (max-height: 400px) { + .share-card { + grid-template-columns: minmax(190px, 2fr) minmax(0, 5fr); + } + + .share-identity { + padding: 10px 15px; + } + + .share-kicker { + font-size: 14px; + } + + .share-person-seal { + width: 58px; + height: 58px; + margin: 5px 0 2px; + font-size: 29px; + } + + .share-person { + font-size: 19px; + } + + .share-era, + .share-chapter { + font-size: 15px; + } + + .share-main { + padding: 42px 12px 10px; + gap: 8px; + } + + .share-section { + gap: 8px; + padding: 7px 8px; + grid-template-columns: 78px minmax(0, 1fr); + } + + .share-section-label { + font-size: 15px; + } + + .share-quote, + .share-section-copy { + font-size: 16px; + line-height: 1.38; + } + + .share-actions { + gap: 8px; + } + + .share-action { + min-height: 48px; + padding: 6px 10px; + font-size: 18px; + } + + .share-invalid { + min-height: 220px; + padding: 17px 24px; + } +} diff --git a/TUICallKit-Vue3/native/tang-detective/utils/chapterProgress.js b/TUICallKit-Vue3/native/tang-detective/utils/chapterProgress.js new file mode 100644 index 0000000..dcb4af4 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/utils/chapterProgress.js @@ -0,0 +1,148 @@ +function eventIdSet(chapter) { + return new Set( + Array.isArray(chapter && chapter.events) + ? chapter.events.map((event) => event.hotspotId) + : [], + ) +} + +function normalizeCompletedIds(chapter, completedIds) { + const knownIds = eventIdSet(chapter) + const seen = new Set() + return (Array.isArray(completedIds) ? completedIds : []).filter((id) => { + if (!knownIds.has(id) || seen.has(id)) return false + seen.add(id) + return true + }) +} + +function getPeopleProgress(chapter, completedIds) { + const knownIds = eventIdSet(chapter) + const completed = new Set(normalizeCompletedIds(chapter, completedIds)) + const people = Array.isArray(chapter && chapter.people) ? chapter.people : [] + + return people + .map((person) => { + const personEventIds = (Array.isArray(person.eventIds) + ? person.eventIds + : [] + ).filter((id) => knownIds.has(id)) + const completedForPerson = personEventIds.filter((id) => completed.has(id)) + const remaining = personEventIds.length - completedForPerson.length + if (!personEventIds.length) return null + + let markerStatus = '看看' + let statusAria = `${person.name}有1处故事细节,可以看看发生了什么` + if (remaining === 0) { + markerStatus = '看过' + statusAria = `${person.name}这一处已经看过` + } else if (completedForPerson.length > 0) { + markerStatus = '再看看' + statusAria = `${person.name}还有${remaining}处细节,可以再看看` + } else if (remaining > 1) { + markerStatus = `${remaining}处` + statusAria = `${person.name}有${remaining}处故事细节` + } + + return { + ...person, + eventIds: personEventIds, + completed: remaining === 0, + completedEvents: completedForPerson.length, + remaining, + markerStatus, + statusAria, + } + }) + .filter(Boolean) +} + +function selectNextPerson(people, activeInstanceId) { + const active = people.find( + (person) => person.instanceId === activeInstanceId && person.remaining > 0, + ) + if (active) return active + + const partiallyCompleted = people.find( + (person) => person.completedEvents > 0 && person.remaining > 0, + ) + return partiallyCompleted || people.find((person) => person.remaining > 0) || null +} + +function getChapterProgress( + chapter, + completedIds, + activeInstanceId = '', + chapterFinished = false, +) { + const normalizedIds = normalizeCompletedIds(chapter, completedIds) + const totalEvents = Array.isArray(chapter && chapter.events) + ? chapter.events.length + : 0 + const completedCount = normalizedIds.length + const remainingCount = Math.max(0, totalEvents - completedCount) + const complete = totalEvents > 0 && remainingCount === 0 + const people = getPeopleProgress(chapter, normalizedIds) + const nextPerson = complete + ? null + : selectNextPerson(people, activeInstanceId) + const repeatPerson = Boolean( + nextPerson + && ( + nextPerson.completedEvents > 0 + || ( + activeInstanceId + && nextPerson.instanceId === activeInstanceId + ) + ), + ) + + let nextStepLabel = '下一步' + let nextStepText = '看看画中的人物和手边物件,跟着这一回往下走。' + let continueLabel = '完成后继续' + if (complete) { + nextStepLabel = chapterFinished ? '本回已收好' : '4处线索已经看过' + nextStepText = chapterFinished + ? '这一回已经收进画册,也可以再次打开情感互动。' + : '情感互动已经开启,点右侧按钮打开这一页。' + continueLabel = '4处线索看完,打开情感互动' + } else if (nextPerson && repeatPerson) { + nextStepText = `还差${remainingCount}处。再看看${nextPerson.name}的手边。` + continueLabel = `下一步:再看看${nextPerson.name}` + } else if (nextPerson) { + const multiEventNote = nextPerson.remaining > 1 + ? ` ${nextPerson.name}身边还有${nextPerson.remaining}处细节。` + : '' + nextStepText = `还差${remainingCount}处。接着看看${nextPerson.name}。${multiEventNote}` + continueLabel = `下一步:看看${nextPerson.name}` + } + + const emotionStatusText = complete + ? ( + chapterFinished + ? '本回已收进画册,可再次打开' + : `${totalEvents}处线索已经看过,可以打开` + ) + : `还差${remainingCount}处线索,看完后开启` + + return { + completedIds: normalizedIds, + completedCount, + totalEvents, + remainingCount, + complete, + people, + nextPerson, + nextAction: complete ? 'complete' : (repeatPerson ? 'repeat' : 'next'), + nextStepLabel, + nextStepText, + continueLabel, + emotionStatusText, + } +} + +module.exports = { + normalizeCompletedIds, + getPeopleProgress, + getChapterProgress, +} diff --git a/TUICallKit-Vue3/native/tang-detective/utils/chapterRoute.js b/TUICallKit-Vue3/native/tang-detective/utils/chapterRoute.js new file mode 100644 index 0000000..e5f13b3 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/utils/chapterRoute.js @@ -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, +} diff --git a/TUICallKit-Vue3/native/tang-detective/utils/comicLayout.js b/TUICallKit-Vue3/native/tang-detective/utils/comicLayout.js new file mode 100644 index 0000000..59a29e9 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/utils/comicLayout.js @@ -0,0 +1,83 @@ +const DEFAULT_COMIC_ART_ASPECT_RATIO = 16 / 9 +const MIN_COMIC_ART_ASPECT_RATIO = 1.4 +const MAX_COMIC_ART_ASPECT_RATIO = 3 + +function normalizeComicArtAspectRatio(page) { + const requested = Number(page && page.artAspectRatio) + if ( + Number.isFinite(requested) + && requested >= MIN_COMIC_ART_ASPECT_RATIO + && requested <= MAX_COMIC_ART_ASPECT_RATIO + ) { + return requested + } + return DEFAULT_COMIC_ART_ASPECT_RATIO +} + +function getComicArtStageStyle(metrics, page) { + const safeMetrics = metrics || {} + const contentWidth = Math.max( + 1, + Number(safeMetrics.windowWidth || 1) + - Number(safeMetrics.safeLeft || 0) + - Number(safeMetrics.safeRight || 0), + ) + const contentHeight = Math.max( + 1, + Number(safeMetrics.windowHeight || 1) + - Number(safeMetrics.topbarHeight || 0) + - Number(safeMetrics.safeBottom || 0), + ) + const aspectRatio = normalizeComicArtAspectRatio(page) + + if (safeMetrics.compactHeight) { + // A phone in landscape does not have enough height for a 16:9 picture + // plus an elder-readable caption below it. Compact pages therefore open + // like a two-page lianhuanhua spread: illustration on the left, caption + // paper on the right. Keep these numbers in lockstep with chapter.wxss. + const captionWidth = Math.max(190, contentWidth * 0.28) + const artColumnWidth = Math.max(1, contentWidth - captionWidth) + const artColumnPadding = 12 + const availableWidth = Math.max(1, artColumnWidth - artColumnPadding) + const availableHeight = Math.max(1, contentHeight - artColumnPadding) + const artWidth = Math.max( + 1, + Math.min(availableWidth, availableHeight * aspectRatio), + ) + const artHeight = artWidth / aspectRatio + return [ + `width:${Math.round(artWidth)}px`, + `height:${Math.round(artHeight)}px`, + ].join(';') + } + + // Keep this in lockstep with chapter.wxss: + // regular: minmax(0, 3fr) minmax(190px, 1fr) + // When the caption track reaches its minimum, the art receives the + // remainder. Calculating that exact height prevents max-height from + // compressing only the stage height and stretching a 16:9 illustration. + const proportionalArtHeight = contentHeight * 0.75 + const captionMinimum = 190 + const artRowHeight = Math.max( + 1, + Math.min( + proportionalArtHeight, + contentHeight - captionMinimum, + ), + ) + const artWidth = Math.max( + 1, + Math.min(contentWidth, artRowHeight * aspectRatio), + ) + const artHeight = artWidth / aspectRatio + return [ + `width:${Math.round(artWidth)}px`, + `height:${Math.round(artHeight)}px`, + ].join(';') +} + +module.exports = { + DEFAULT_COMIC_ART_ASPECT_RATIO, + normalizeComicArtAspectRatio, + getComicArtStageStyle, +} diff --git a/TUICallKit-Vue3/native/tang-detective/utils/layout.js b/TUICallKit-Vue3/native/tang-detective/utils/layout.js new file mode 100644 index 0000000..1aea4f7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/utils/layout.js @@ -0,0 +1,179 @@ +function finiteNumber(value, fallback = 0) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)) +} + +const SCENE_WIDTH = 1400 +const SCENE_HEIGHT = 788 +const SCENE_ASPECT_RATIO = SCENE_WIDTH / SCENE_HEIGHT + +function getChapterLayoutMetrics(windowInfo = {}, menuRect = {}) { + const windowWidth = Math.max(320, finiteNumber(windowInfo.windowWidth, 844)) + const windowHeight = Math.max(240, finiteNumber(windowInfo.windowHeight, 390)) + const safeArea = windowInfo.safeArea || {} + const safeLeft = clamp(finiteNumber(safeArea.left, 0), 0, windowWidth / 3) + const safeRightEdge = clamp( + finiteNumber(safeArea.right, windowWidth), + windowWidth * 2 / 3, + windowWidth, + ) + const safeRight = clamp(windowWidth - safeRightEdge, 0, windowWidth / 3) + const statusBarHeight = Math.max( + 0, + finiteNumber(windowInfo.statusBarHeight, 0), + ) + const safeTop = Math.max( + 0, + statusBarHeight, + finiteNumber(safeArea.top, 0), + ) + const safeBottomEdge = clamp( + finiteNumber(safeArea.bottom, windowHeight), + windowHeight * 2 / 3, + windowHeight, + ) + const safeBottom = clamp( + windowHeight - safeBottomEdge, + 0, + windowHeight / 3, + ) + const menuLeft = finiteNumber(menuRect.left, windowWidth) + const hasExplicitMenuTop = Number.isFinite(Number(menuRect.top)) + && Number(menuRect.top) >= 0 + const menuTop = hasExplicitMenuTop ? Number(menuRect.top) : 0 + const menuBottom = finiteNumber(menuRect.bottom, 0) + const explicitMenuHeight = Number(menuRect.height) + const menuHeight = Number.isFinite(explicitMenuHeight) + && explicitMenuHeight > 0 + ? explicitMenuHeight + : ( + hasExplicitMenuTop && menuBottom > menuTop + ? menuBottom - menuTop + : 32 + ) + const hasMenuRect = menuLeft > 0 + && menuLeft < windowWidth + && menuBottom > 0 + + // 横屏 rpx 按屏幕宽度换算;576px 高的设备仍属于短高屏。 + // 这里使用实际 windowHeight,而不是设备型号或像素比。 + const compactHeight = windowHeight <= 620 + // Every visible top-bar action has a 48px minimum target. Keep the + // calculated row at least as tall so compact landscape phones do not clip + // the button above or below the explicit top-bar height. + const rowHeight = Math.max(48, menuHeight) + const inferredMenuTop = hasMenuRect + ? ( + hasExplicitMenuTop + ? menuTop + : Math.max(safeTop, menuBottom - menuHeight) + ) + : safeTop + const topPadding = Math.max( + safeTop, + hasMenuRect + ? inferredMenuTop - Math.max(0, (rowHeight - menuHeight) / 2) + : safeTop + (compactHeight ? 3 : 5), + ) + const bottomPadding = compactHeight ? 4 : 6 + const borderHeight = 3 + const topbarHeight = Math.ceil( + topPadding + rowHeight + bottomPadding + borderHeight, + ) + const leftInset = safeLeft + (compactHeight ? 10 : 14) + const capsuleReserve = hasMenuRect + ? Math.max( + safeRight + (compactHeight ? 10 : 14), + windowWidth - menuLeft + (compactHeight ? 8 : 10), + ) + : safeRight + (compactHeight ? 12 : 16) + const contentHeight = Math.max( + 1, + windowHeight - topbarHeight - safeBottom, + ) + const layoutWidth = Math.max( + 1, + windowWidth - safeLeft - safeRight, + ) + const sceneColumnWidth = layoutWidth * 0.6 + const stageWidth = Math.max( + 1, + Math.min(sceneColumnWidth, contentHeight * SCENE_ASPECT_RATIO), + ) + const stageHeight = stageWidth / SCENE_ASPECT_RATIO + const markerWidth = compactHeight + ? clamp(stageWidth * 0.24, 142, 170) + : clamp(windowWidth / 750 * 200, 160, 220) + const markerHeight = compactHeight + ? 56 + : clamp(windowWidth / 750 * 86, 64, 94) + + return { + windowWidth, + windowHeight, + compactHeight, + safeLeft, + safeRight, + safeTop, + safeBottom, + topPadding, + bottomPadding, + rowHeight, + topbarHeight, + leftInset, + capsuleReserve, + modalTop: Math.max( + safeTop + (compactHeight ? 4 : 8), + hasMenuRect ? menuBottom + 4 : 0, + ), + stageWidth: Math.round(stageWidth), + stageHeight: Math.round(stageHeight), + markerWidth: Math.round(markerWidth), + markerHeight: Math.round(markerHeight), + sceneWidth: SCENE_WIDTH, + sceneHeight: SCENE_HEIGHT, + sceneAspectRatio: SCENE_ASPECT_RATIO, + } +} + +function getMarkerPosition(position = {}, metrics = {}) { + const widthPercent = finiteNumber(position.widthPercent, 0) + const heightPercent = finiteNumber(position.heightPercent, 0) + const rawX = finiteNumber(position.xPercent, 50) + widthPercent / 2 + const rawY = finiteNumber(position.yPercent, 50) + heightPercent / 2 + const stageWidth = Math.max(1, finiteNumber(metrics.stageWidth, 500)) + const stageHeight = Math.max(1, finiteNumber(metrics.stageHeight, 280)) + const markerWidth = finiteNumber(metrics.markerWidth, 168) + const markerHeight = finiteNumber(metrics.markerHeight, 64) + const horizontalMargin = clamp( + markerWidth / 2 / stageWidth * 100 + 1.5, + 2, + 48, + ) + const verticalMargin = clamp( + markerHeight / 2 / stageHeight * 100 + 2, + 2, + 48, + ) + const clampedX = clamp(rawX, horizontalMargin, 100 - horizontalMargin) + const clampedY = clamp(rawY, verticalMargin, 100 - verticalMargin) + + return { + xPercent: clampedX, + yPercent: clampedY, + xPx: Math.round(clampedX / 100 * stageWidth), + yPx: Math.round(clampedY / 100 * stageHeight), + } +} + +module.exports = { + SCENE_WIDTH, + SCENE_HEIGHT, + SCENE_ASPECT_RATIO, + getChapterLayoutMetrics, + getMarkerPosition, +} diff --git a/TUICallKit-Vue3/native/tang-detective/utils/memoryCollection.js b/TUICallKit-Vue3/native/tang-detective/utils/memoryCollection.js new file mode 100644 index 0000000..7780381 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/utils/memoryCollection.js @@ -0,0 +1,86 @@ +const SNAPSHOT_FIELDS = [ + 'cardId', + 'chapterId', + 'chapterNumber', + 'chapterTitle', + 'characterName', + 'eraLine', + 'tableEcho', + 'lifeAction', + 'familyLine', + 'eraObject', +] + +function cleanSnapshot(source = {}) { + return SNAPSHOT_FIELDS.reduce((snapshot, field) => { + const value = source[field] + if (field === 'chapterNumber') { + const number = Number(value) + if (number > 0) snapshot[field] = number + } else if (typeof value === 'string' && value.trim()) { + snapshot[field] = value.trim() + } + return snapshot + }, {}) +} + +function normalizeProgress(progress) { + const next = progress && typeof progress === 'object' + ? { ...progress } + : {} + next.collectedMemoryCards = Array.isArray(next.collectedMemoryCards) + ? [...new Set(next.collectedMemoryCards.filter((id) => typeof id === 'string' && id))] + : [] + next.memoryCardSnapshots = next.memoryCardSnapshots + && typeof next.memoryCardSnapshots === 'object' + && !Array.isArray(next.memoryCardSnapshots) + ? { ...next.memoryCardSnapshots } + : {} + return next +} + +function addMemoryCard(progress, card, chapter = {}) { + const next = normalizeProgress(progress) + if (!card || !card.cardId) return next + const snapshot = cleanSnapshot({ + ...chapter, + ...card, + chapterId: chapter.chapterId || card.chapterId, + chapterNumber: chapter.chapterNumber || card.chapterNumber, + chapterTitle: chapter.chapterTitle || chapter.title || card.chapterTitle, + }) + if (!snapshot.cardId) return next + if (!next.collectedMemoryCards.includes(snapshot.cardId)) { + next.collectedMemoryCards.push(snapshot.cardId) + } + next.memoryCardSnapshots[snapshot.cardId] = snapshot + return next +} + +function getCollectedMemories(progress, catalog = []) { + const normalized = normalizeProgress(progress) + const catalogById = (Array.isArray(catalog) ? catalog : []).reduce( + (map, card) => { + if (card && card.cardId) map[card.cardId] = cleanSnapshot(card) + return map + }, + {}, + ) + + return normalized.collectedMemoryCards + .map((cardId) => { + const fallback = catalogById[cardId] || {} + const saved = cleanSnapshot(normalized.memoryCardSnapshots[cardId] || {}) + const card = cleanSnapshot({ ...fallback, ...saved, cardId }) + return card.chapterNumber ? card : null + }) + .filter(Boolean) + .sort((a, b) => a.chapterNumber - b.chapterNumber) +} + +module.exports = { + addMemoryCard, + cleanSnapshot, + getCollectedMemories, + normalizeProgress, +} diff --git a/TUICallKit-Vue3/native/tang-detective/utils/storage.js b/TUICallKit-Vue3/native/tang-detective/utils/storage.js new file mode 100644 index 0000000..e8969da --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/utils/storage.js @@ -0,0 +1,142 @@ +const PROGRESS_KEY = 'tang-detective-progress-v1' +const SETTINGS_KEY = 'tang-detective-settings-v1' +const AUDIO_KEY = 'tang-detective-audio-progress-v1' + +function defaultProgress() { + return { + completedHotspots: {}, + completedChapters: [], + lastChapter: 1, + } +} + +function isPlainRecord(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function chapterId(number) { + return `S01-C${String(number).padStart(2, '0')}` +} + +function eventId(number) { + return `S01-H${String(number).padStart(2, '0')}` +} + +function normalizeCompletedHotspots(value) { + const source = isPlainRecord(value) ? value : {} + const normalized = {} + for (let chapterNumber = 1; chapterNumber <= 15; chapterNumber += 1) { + const id = chapterId(chapterNumber) + const stored = Array.isArray(source[id]) ? source[id] : [] + const firstEvent = (chapterNumber - 1) * 4 + 1 + const allowed = new Set( + Array.from({ length: 4 }, (_, index) => eventId(firstEvent + index)), + ) + const clean = [...new Set(stored.filter((item) => allowed.has(item)))] + if (clean.length > 0 || Object.prototype.hasOwnProperty.call(source, id)) { + normalized[id] = clean + } + } + return normalized +} + +function normalizeCompletedChapters(value) { + if (!Array.isArray(value)) return [] + const allowed = new Set( + Array.from({ length: 15 }, (_, index) => chapterId(index + 1)), + ) + return [...new Set(value.filter((item) => allowed.has(item)))] +} + +function normalizeProgress(value) { + const source = isPlainRecord(value) ? value : {} + const lastChapter = Number(source.lastChapter) + return { + ...source, + completedHotspots: normalizeCompletedHotspots(source.completedHotspots), + completedChapters: normalizeCompletedChapters(source.completedChapters), + lastChapter: Number.isInteger(lastChapter) && lastChapter >= 1 && lastChapter <= 15 + ? lastChapter + : 1, + } +} + +function normalizeSettings(value) { + const source = isPlainRecord(value) ? value : {} + return { + ...source, + fontScale: source.fontScale === 'xlarge' ? 'xlarge' : 'large', + sound: source.sound !== false, + } +} + +function read(key, fallback) { + try { + const value = wx.getStorageSync(key) + return value || fallback + } catch (error) { + return fallback + } +} + +function write(key, value) { + try { + wx.setStorageSync(key, value) + return true + } catch (error) { + // Storage failure must never block the text game. + return false + } +} + +function getProgress() { + return normalizeProgress(read(PROGRESS_KEY, null)) +} + +function saveProgress(progress) { + return write(PROGRESS_KEY, normalizeProgress(progress)) +} + +/** + * Start the story again without deleting the reader's collected memory cards + * or accessibility preferences. Only story/level progress is reset. + */ +function resetStoryProgress() { + const current = getProgress() + const next = { + ...current, + ...defaultProgress(), + } + delete next.comicReaderByChapter + delete next.lastPageId + if (!saveProgress(next)) return false + write(AUDIO_KEY, {}) + return true +} + +function getSettings() { + return normalizeSettings(read(SETTINGS_KEY, null)) +} + +function saveSettings(settings) { + return write(SETTINGS_KEY, normalizeSettings(settings)) +} + +function getAudioProgress() { + const value = read(AUDIO_KEY, {}) + return isPlainRecord(value) ? value : {} +} + +function saveAudioProgress(progress) { + write(AUDIO_KEY, progress) +} + +module.exports = { + getProgress, + saveProgress, + resetStoryProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} diff --git a/TUICallKit-Vue3/native/tang-detective/utils/updateManager.js b/TUICallKit-Vue3/native/tang-detective/utils/updateManager.js new file mode 100644 index 0000000..062bfd7 --- /dev/null +++ b/TUICallKit-Vue3/native/tang-detective/utils/updateManager.js @@ -0,0 +1,48 @@ +function showUpdateReady(updatePlatform, manager) { + if (!updatePlatform || typeof updatePlatform.showModal !== 'function') return + updatePlatform.showModal({ + title: '新版本已经准备好', + content: '重新打开后即可使用新版本。现在更新吗?', + confirmText: '现在更新', + cancelText: '稍后再说', + success(result) { + if (result && result.confirm && typeof manager.applyUpdate === 'function') { + manager.applyUpdate() + } + }, + }) +} + +function showUpdateFailed(updatePlatform) { + if (!updatePlatform || typeof updatePlatform.showModal !== 'function') return + updatePlatform.showModal({ + title: '新版本暂时没有下载完成', + content: '当前内容仍可继续使用。请检查网络后,完全退出微信再重新打开。', + showCancel: false, + confirmText: '知道了', + }) +} + +function setupUpdateManager(updatePlatform) { + if (!updatePlatform || typeof updatePlatform.getUpdateManager !== 'function') return false + try { + const manager = updatePlatform.getUpdateManager() + if (!manager) return false + + if (typeof manager.onUpdateReady === 'function') { + manager.onUpdateReady(() => showUpdateReady(updatePlatform, manager)) + } + if (typeof manager.onUpdateFailed === 'function') { + manager.onUpdateFailed(() => showUpdateFailed(updatePlatform)) + } + return true + } catch (error) { + return false + } +} + +module.exports = { + setupUpdateManager, + showUpdateFailed, + showUpdateReady, +} diff --git a/TUICallKit-Vue3/scripts/check-tang-native-compiler.cjs b/TUICallKit-Vue3/scripts/check-tang-native-compiler.cjs new file mode 100644 index 0000000..a60fb0c --- /dev/null +++ b/TUICallKit-Vue3/scripts/check-tang-native-compiler.cjs @@ -0,0 +1,25 @@ +// Uses the installed WeChat compiler binaries only. No DevTools UI, server, +// account, network, upload, media playback or files outside the build are used. +const fs = require('node:fs') +const path = require('node:path') +const { spawnSync } = require('node:child_process') +const root = path.resolve(__dirname, '../dist/build/mp-weixin') +const bin = process.env.TANG_WECHAT_COMPILER_DIR || '/Applications/wechatwebdevtools.app/Contents/Resources/app.asar.unpacked/node_modules/wcc-exec' +const app = JSON.parse(fs.readFileSync(path.join(root, 'app.json'), 'utf8')) +const pages = [...app.pages, ...(app.subPackages || []).flatMap(pkg => pkg.pages.map(page => `${pkg.root}/${page}`))] + .filter(page => page.startsWith('tang-detective/')) +if (pages.length !== 24) throw new Error(`Expected 24 native pages, found ${pages.length}`) +const results = [] +for (const [tool, args] of [ + ['wcc', pages.map(page => './' + page + '.wxml')], + ['wcsc', ['-pc', String(pages.length), ...pages.map(page => './' + page + '.wxss'), './tang-detective/shared.wxss']], +]) { + if (!fs.existsSync(path.join(bin, tool))) throw new Error(`${tool} not available; set TANG_WECHAT_COMPILER_DIR`) + const run = spawnSync(path.join(bin, tool), args, { cwd: root, encoding: 'utf8', timeout: 30000, maxBuffer: 64 * 1024 * 1024 }) + results.push({ tool, exitCode: run.status, passed: run.status === 0 && !run.error, + generatedOutputBytes: Buffer.byteLength(run.stdout || ''), + diagnostics: (run.stderr || '').slice(0, 3000), error: run.error ? run.error.message : null }) +} +console.log(JSON.stringify({ nativePages: pages.length, results, + boundary: 'Installed compiler syntax check only, not simulator/device, networking or upload acceptance.' }, null, 2)) +if (results.some(result => !result.passed)) process.exitCode = 1 diff --git a/TUICallKit-Vue3/scripts/tang-cos/config.mjs b/TUICallKit-Vue3/scripts/tang-cos/config.mjs new file mode 100644 index 0000000..df8057f --- /dev/null +++ b/TUICallKit-Vue3/scripts/tang-cos/config.mjs @@ -0,0 +1,95 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' + +export function dependency(name) { + const root = process.env.TANG_COS_TOOLS_DIR + return createRequire(root ? path.join(path.resolve(root), 'package.json') : import.meta.url)(name) +} + +// Fail closed rather than evaluating PHP or guessing deployment settings. +export function configuredDatabase(serverDirectory, environment = process.env) { + const filename = path.join(serverDirectory, 'config/database.php') + const source = fs.readFileSync(filename, 'utf8') + if (fs.existsSync(path.join(serverDirectory, '.env'))) { + throw new Error('SERVER_ENV_REQUIRES_NATIVE_RUNTIME') + } + function value(key) { + const envKey = `DATABASE_${key.toUpperCase()}` + if (environment[envKey] !== undefined) return environment[envKey] + if (environment[`PHP_${envKey}`] !== undefined) return environment[`PHP_${envKey}`] + const match = source.match(new RegExp(`env\\('database\\.${key}',\\s*'((?:\\\\.|[^'\\\\])*)'\\)`)) + if (!match) throw new Error('UNSUPPORTED_DATABASE_CONFIG') + return match[1].replace(/\\([\\'])/g, '$1') + } + const prefix = value('prefix') + const port = Number(value('hostport')) + if (!/^[a-zA-Z0-9_]+$/.test(prefix) || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('INVALID_DATABASE_CONFIG') + } + const options = { + host: value('hostname'), port, database: value('database'), user: value('username'), password: value('password'), + connectTimeout: 8000, multipleStatements: false, + ssl: { rejectUnauthorized: true, verifyIdentity: true }, + } + // Supply only a trusted CA obtained from the server administrator; never fetch and trust a peer certificate. + if (environment.TANG_DB_CA_FILE) options.ssl.ca = fs.readFileSync(environment.TANG_DB_CA_FILE, 'utf8') + return { options, prefix } +} + +export function validateCosConfig(config, driver) { + if (driver !== 'qcloud') throw new Error('CONFIGURED_DRIVER_IS_NOT_COS') + if (!/^[a-z0-9][a-z0-9-]*-\d+$/.test(config.bucket || '') || !/^[a-z][a-z0-9-]+$/.test(config.region || '')) { + throw new Error('INVALID_COS_DESTINATION') + } + if (typeof config.access_key !== 'string' || !config.access_key || typeof config.secret_key !== 'string' || !config.secret_key) { + throw new Error('COS_CREDENTIALS_MISSING') + } + const rawDomain = String(config.domain || '').trim() + const base = new URL(rawDomain ? (/^https?:\/\//i.test(rawDomain) ? rawDomain : `https://${rawDomain}`) + : `https://${config.bucket}.cos.${config.region}.myqcloud.com`) + if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) { + throw new Error('COS_REQUIRES_UNSIGNED_HTTPS_BASE_URL') + } + return { ...config, baseUrl: base.href.replace(/\/+$/, '') } +} + +export async function withDeadline(operation, milliseconds, abort, code) { + let timer + try { + return await Promise.race([operation, new Promise((_, reject) => { + timer = setTimeout(() => { + try { abort() } catch {} + reject(new Error(code)) + }, milliseconds) + })]) + } finally { + clearTimeout(timer) + } +} + +export async function readCosConfig(serverDirectory) { + const mysql = dependency('mysql2') + const { options, prefix } = configuredDatabase(serverDirectory) + let connection + let destroyed = false + const abort = () => { destroyed = true; try { connection?.destroy() } catch {} } + try { + connection = mysql.createConnection(options).promise() + await withDeadline(connection.connect(), 8000, abort, 'DATABASE_CONNECT_TIMEOUT') + const [rows] = await withDeadline(connection.execute(`SELECT name,value FROM \`${prefix}config\` WHERE type=? AND name IN (?,?)`, + ['storage', 'default', 'qcloud']), 8000, abort, 'DATABASE_QUERY_TIMEOUT') + const config = JSON.parse(rows.find(row => row.name === 'qcloud')?.value || '{}') + return validateCosConfig(config, rows.find(row => row.name === 'default')?.value) + } finally { + if (connection && !destroyed) { + try { await withDeadline(connection.end(), 2000, abort, 'DATABASE_CLOSE_TIMEOUT') } catch { abort() } + } + } +} + +// Never print raw MySQL/COS errors: they may contain connection values or signed request URLs. +export function safeError(error) { + const code = String(error?.code || error?.message || '') + return /^[A-Z][A-Z0-9_]{2,80}$/.test(code) ? code : 'REDACTED_OPERATION_ERROR' +} diff --git a/TUICallKit-Vue3/scripts/tang-cos/package-lock.json b/TUICallKit-Vue3/scripts/tang-cos/package-lock.json new file mode 100644 index 0000000..98181f5 --- /dev/null +++ b/TUICallKit-Vue3/scripts/tang-cos/package-lock.json @@ -0,0 +1,909 @@ +{ + "name": "tang-detective-cos-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tang-detective-cos-tools", + "dependencies": { + "cos-nodejs-sdk-v5": "3.0.0", + "mysql2": "3.24.4" + } + }, + "node_modules/@types/node": { + "version": "26.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.0.tgz", + "integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/cos-fast-xml-parser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cos-fast-xml-parser/-/cos-fast-xml-parser-1.0.0.tgz", + "integrity": "sha512-kOPJb1cuj+gc5E8jN5ekZn4rgQHaxYTLcQT+jmbHtlTNcNjv7wnOBEYH2uRA0pG3h8giot4z9h4WFLDuXATZwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + }, + "engines": { + "node": ">= 9" + } + }, + "node_modules/cos-nodejs-sdk-v5": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cos-nodejs-sdk-v5/-/cos-nodejs-sdk-v5-3.0.0.tgz", + "integrity": "sha512-xUqiDdUxfEjfaWoyBA3qsSnlQVHPz13DRFErL+NliadBVUeeZhAPHGVa2inGpUvPTOs52ALIKkMbzL+inSqvXg==", + "license": "ISC", + "dependencies": { + "cos-fast-xml-parser": "^1.0.0", + "cos-request": "^1.3.0", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 9" + } + }, + "node_modules/cos-request": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/cos-request/-/cos-request-1.3.3.tgz", + "integrity": "sha512-zD7fKMAIMfJNHssx8VZE5mUQ67hs3QDi7S2BX7JuD8MzT1XYqEOAN42PJ0BtRhgutnn+8HiUF+Fup+j5zORasQ==", + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.5.6", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "^6.15.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~4.1.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", + "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mysql2": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz", + "integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT", + "peer": true + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + } + } +} diff --git a/TUICallKit-Vue3/scripts/tang-cos/package.json b/TUICallKit-Vue3/scripts/tang-cos/package.json new file mode 100644 index 0000000..6ea5b65 --- /dev/null +++ b/TUICallKit-Vue3/scripts/tang-cos/package.json @@ -0,0 +1,9 @@ +{ + "name": "tang-detective-cos-tools", + "private": true, + "type": "module", + "dependencies": { + "cos-nodejs-sdk-v5": "3.0.0", + "mysql2": "3.24.4" + } +} diff --git a/TUICallKit-Vue3/scripts/tang-cos/upload.mjs b/TUICallKit-Vue3/scripts/tang-cos/upload.mjs new file mode 100644 index 0000000..b872fe9 --- /dev/null +++ b/TUICallKit-Vue3/scripts/tang-cos/upload.mjs @@ -0,0 +1,228 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { dependency, readCosConfig, safeError } from './config.mjs' + +export const PROJECT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const MEDIA = { + '.jpg': ['image', 'image/jpeg'], '.jpeg': ['image', 'image/jpeg'], '.png': ['image', 'image/png'], + '.webp': ['image', 'image/webp'], '.gif': ['image', 'image/gif'], '.svg': ['image', 'image/svg+xml'], + '.mp3': ['audio', 'audio/mpeg'], '.wav': ['audio', 'audio/wav'], '.m4a': ['audio', 'audio/mp4'], + '.aac': ['audio', 'audio/aac'], '.ogg': ['audio', 'audio/ogg'], '.mp4': ['video', 'video/mp4'], + '.webm': ['video', 'video/webm'], '.mov': ['video', 'video/quicktime'], +} +export const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex') + +function walk(directory, prefix = '') { + return fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap(entry => { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isSymbolicLink()) throw new Error('SOURCE_SYMLINK_NOT_ALLOWED') + return entry.isDirectory() ? walk(path.join(directory, entry.name), relative) : [relative] + }) +} + +export function inventory(project = PROJECT) { + const sourceDirectory = path.join(project, 'native/tang-detective') + const manifestBytes = fs.readFileSync(path.join(project, 'build/tang-detective-source-manifest.json')) + const source = JSON.parse(manifestBytes) + const files = walk(sourceDirectory) + const expected = new Map(source.files.map(entry => [entry.path, entry])) + if (files.length !== expected.size || files.some(file => !expected.has(file))) throw new Error('SOURCE_FILE_SET_CHANGED') + const entries = [] + for (const relative of files) { + const bytes = fs.readFileSync(path.join(sourceDirectory, relative)) + const sha256 = hash(bytes) + const original = expected.get(relative) + if (original.bytes !== bytes.length || original.sha256 !== sha256) throw new Error('SOURCE_BYTES_CHANGED') + const extension = path.extname(relative).toLowerCase() + if (!MEDIA[extension]) continue + const [kind, contentType] = MEDIA[extension] + entries.push({ sourcePath: relative, kind, contentType, bytes: bytes.length, sha256, + objectKey: `tang-detective/season-01/media-v1/${sha256}${extension}` }) + } + if (!entries.length) throw new Error('NO_SOURCE_MEDIA') + return { sourceDirectory, sourceManifestSha256: hash(manifestBytes), entries } +} + +function writeJson(filename, value) { + fs.mkdirSync(path.dirname(filename), { recursive: true }) + const temp = `${filename}.${process.pid}.tmp` + fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' }) + fs.renameSync(temp, filename) +} + +export function objectUrl(baseUrl, objectKey) { + if (!/^tang-detective\/season-01\/media-v1\/[a-f0-9]{64}\.[a-z0-9]+$/.test(objectKey)) { + throw new Error('UNSAFE_OBJECT_KEY') + } + const url = new URL(`${baseUrl}/${objectKey}`) + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) throw new Error('UNSAFE_OBJECT_URL') + return url.href +} + +async function boundedBody(response, limit) { + let length = 0 + const chunks = [] + for await (const chunk of response.body) { + length += chunk.length + if (length > limit) throw new Error('REMOTE_BODY_TOO_LARGE') + chunks.push(chunk) + } + return Buffer.concat(chunks) +} + +export async function verifyPublicObject(entry, fetchImpl = fetch) { + const response = await fetchImpl(entry.url, { redirect: 'error', signal: AbortSignal.timeout(20000) }) + if (response.status !== 200) { await response.body?.cancel(); throw new Error('PUBLIC_MEDIA_GET_FAILED') } + const bytes = await boundedBody(response, entry.bytes) + if (bytes.length !== entry.bytes || hash(bytes) !== entry.sha256) throw new Error('PUBLIC_MEDIA_HASH_MISMATCH') + if (response.headers.get('content-type')?.split(';')[0].trim() !== entry.contentType) throw new Error('PUBLIC_MEDIA_TYPE_MISMATCH') + let rangeVerified = null + if (entry.kind === 'audio' || entry.kind === 'video') { + const last = Math.min(1023, entry.bytes - 1) + const range = await fetchImpl(entry.url, { headers: { Range: `bytes=0-${last}` }, redirect: 'error', signal: AbortSignal.timeout(20000) }) + if (range.status !== 206 || range.headers.get('content-range') !== `bytes 0-${last}/${entry.bytes}`) { + await range.body?.cancel(); throw new Error('PUBLIC_MEDIA_RANGE_FAILED') + } + const fragment = await boundedBody(range, last + 1) + if (!fragment.equals(bytes.subarray(0, last + 1))) throw new Error('PUBLIC_MEDIA_RANGE_HASH_MISMATCH') + rangeVerified = true + } + return { remoteVerifiedSha256: hash(bytes), rangeVerified, verifiedAt: new Date().toISOString() } +} + +export async function uploadObject(cos, destination, entry, body, onState = () => {}) { + const params = { Bucket: destination.bucket, Region: destination.region, Key: entry.objectKey } + let exists = false + try { + const head = await cos.headObject(params) + if (Number(head.headers?.['content-length']) !== entry.bytes || head.headers?.['x-cos-meta-sha256'] !== entry.sha256) { + throw new Error('EXISTING_OBJECT_NOT_OWNED_OR_DIFFERENT') + } + exists = true + } catch (error) { + if (Number(error.statusCode) !== 404) throw error + } + if (!exists) { + onState({ uploaded: 'unknown', uploadStatus: 'put-started-outcome-unknown' }) + await cos.putObject({ ...params, Body: body, ContentLength: body.length, ContentType: entry.contentType, + CacheControl: 'public, max-age=31536000, immutable', Headers: { + 'Content-MD5': crypto.createHash('md5').update(body).digest('base64'), + 'x-cos-meta-sha256': entry.sha256, + 'x-cos-forbid-overwrite': 'true', + } }) + } + return { uploaded: true, uploadStatus: 'confirmed', action: exists ? 'reused-identical' : 'uploaded' } +} + +export async function main(mode, dependencies = {}) { + if (!['inventory', 'inspect', 'upload'].includes(mode)) throw new Error('USE_INVENTORY_INSPECT_OR_UPLOAD') + const project = dependencies.project || PROJECT + const readConfig = dependencies.readConfig || readCosConfig + const verify = dependencies.verify || verifyPublicObject + const log = dependencies.log || (value => console.log(JSON.stringify(value))) + if (mode === 'upload') return runUpload({ project, readConfig, verify, log, createCos: dependencies.createCos }) + const input = inventory(project) + const summary = { files: input.entries.length, uniqueObjects: new Set(input.entries.map(e => e.objectKey)).size, + bytes: input.entries.reduce((sum, entry) => sum + entry.bytes, 0), + kinds: Object.fromEntries(['image', 'audio', 'video'].map(kind => [kind, input.entries.filter(e => e.kind === kind).length])) } + if (mode === 'inventory') { log(summary); return } + const config = await readConfig(path.resolve(project, '../server')) + const destination = { bucket: config.bucket, region: config.region, baseUrl: config.baseUrl } + log({ ...summary, destination, credentialsPresent: true, databaseReadOnly: true, tlsVerified: true }) +} + +async function runUpload({ project, readConfig, verify, log, createCos }) { + const runId = crypto.randomUUID() + const receipt = { schemaVersion: 1, runId, startedAt: new Date().toISOString(), phase: 'inventory', + complete: false, bucketPermissionsChanged: false, originalFilesChanged: false, objects: [] } + const receiptPath = path.join(project, 'build/tang-detective-cos-upload-receipt.json') + const historyDirectory = path.join(project, 'build/tang-detective-cos-upload-attempts') + fs.mkdirSync(historyDirectory, { recursive: true }) + if (fs.existsSync(receiptPath)) { + const previous = fs.readFileSync(receiptPath) + const archive = path.join(historyDirectory, `previous-${hash(previous)}.json`) + if (!fs.existsSync(archive)) fs.writeFileSync(archive, previous, { flag: 'wx' }) + } + const persist = () => { + writeJson(path.join(historyDirectory, `${runId}.json`), receipt) + writeJson(receiptPath, receipt) + } + persist() + const completed = new Map() + try { + const input = inventory(project) + const summary = { files: input.entries.length, uniqueObjects: new Set(input.entries.map(e => e.objectKey)).size, + bytes: input.entries.reduce((sum, entry) => sum + entry.bytes, 0) } + receipt.sourceManifestSha256 = input.sourceManifestSha256 + receipt.phase = 'configuration' + persist() + const config = await readConfig(path.resolve(project, '../server')) + const destination = { bucket: config.bucket, region: config.region, baseUrl: config.baseUrl } + receipt.destination = destination + receipt.phase = 'sdk-initialization' + persist() + const options = { SecretId: config.access_key, SecretKey: config.secret_key, Protocol: 'https:', + Timeout: 20000, MaxRetryTimes: 0, UploadCheckContentMd5: true } + const cos = createCos ? createCos(options) : new (dependency('cos-nodejs-sdk-v5'))(options) + log({ ...summary, destination, credentialsPresent: true, databaseReadOnly: true, tlsVerified: true }) + receipt.phase = 'upload-and-verify' + // Sequential first-object verification stops immediately if this bucket/domain isn't anonymously readable. + for (const item of input.entries) { + if (completed.has(item.objectKey)) continue + const entry = { ...item, url: objectUrl(config.baseUrl, item.objectKey) } + const body = fs.readFileSync(path.join(input.sourceDirectory, item.sourcePath)) + if (body.length !== item.bytes || hash(body) !== item.sha256) throw new Error('SOURCE_CHANGED_DURING_UPLOAD') + const result = { ...entry, uploaded: false, uploadStatus: 'not-attempted' } + receipt.objects.push(result) + persist() + try { + Object.assign(result, await uploadObject(cos, destination, entry, body, state => { + Object.assign(result, state) + persist() + })) + persist() + Object.assign(result, await verify(entry)) + completed.set(item.objectKey, result) + persist() + } catch (error) { + result.error = safeError(error) + // A lost PUT response is not proof that nothing was uploaded. Read-only reconciliation can establish it. + if (result.uploaded === 'unknown') { + try { + const head = await cos.headObject({ Bucket: destination.bucket, Region: destination.region, Key: entry.objectKey }) + if (Number(head.headers?.['content-length']) === entry.bytes && head.headers?.['x-cos-meta-sha256'] === entry.sha256) { + Object.assign(result, { uploaded: true, uploadStatus: 'confirmed-by-readback' }, await verify(entry)) + } + } catch { /* Keep unknown; retry checks the exact content-addressed key without overwriting. */ } + } + throw error + } + log({ verified: completed.size, total: summary.uniqueObjects }) + } + // Recheck all source bytes immediately before publishing the build activation manifest. + if (inventory(project).sourceManifestSha256 !== input.sourceManifestSha256) throw new Error('SOURCE_MANIFEST_CHANGED') + const entries = input.entries.map(entry => ({ ...entry, url: completed.get(entry.objectKey).url, uploaded: true, + remoteVerifiedSha256: completed.get(entry.objectKey).remoteVerifiedSha256, + rangeVerified: completed.get(entry.objectKey).rangeVerified, + verifiedAt: completed.get(entry.objectKey).verifiedAt })) + writeJson(path.join(project, 'build/tang-detective-cos-manifest.json'), { + schemaVersion: 1, sourceManifestSha256: input.sourceManifestSha256, destination, entries, + }) + receipt.complete = true + receipt.phase = 'complete' + receipt.completedAt = new Date().toISOString() + log({ complete: true, ...summary }) + } catch (error) { + receipt.error = safeError(error) + receipt.failedAt = new Date().toISOString() + throw error + } finally { + persist() + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + main(process.argv[2]).catch(error => { console.error(JSON.stringify({ complete: false, error: safeError(error) })); process.exitCode = 1 }) +} diff --git a/TUICallKit-Vue3/scripts/tang-cos/upload.test.mjs b/TUICallKit-Vue3/scripts/tang-cos/upload.test.mjs new file mode 100644 index 0000000..a8a473e --- /dev/null +++ b/TUICallKit-Vue3/scripts/tang-cos/upload.test.mjs @@ -0,0 +1,201 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { inventory, hash, objectUrl, verifyPublicObject, uploadObject, main } from './upload.mjs' +import { configuredDatabase, validateCosConfig, safeError, withDeadline } from './config.mjs' + +function fixture(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'tang-cos-test-')) + t.after(() => fs.rmSync(directory, { recursive: true })) + fs.mkdirSync(path.join(directory, 'native/tang-detective'), { recursive: true }) + fs.mkdirSync(path.join(directory, 'build')) + fs.writeFileSync(path.join(directory, 'native/tang-detective/image.jpg'), 'existing-image') + fs.writeFileSync(path.join(directory, 'build/tang-detective-source-manifest.json'), JSON.stringify({ files: [ + { path: 'image.jpg', bytes: 14, sha256: hash('existing-image') }, + ] })) + return directory +} + +test('inventory creates content-addressed keys without changing source files', t => { + const project = fixture(t) + const input = inventory(project) + assert.equal(input.entries.length, 1) + assert.equal(input.entries[0].objectKey, `tang-detective/season-01/media-v1/${hash('existing-image')}.jpg`) + assert.equal(fs.readFileSync(path.join(project, 'native/tang-detective/image.jpg'), 'utf8'), 'existing-image') +}) + +test('inventory refuses changed bytes, unrecorded files, and symlinks', t => { + const project = fixture(t) + const source = path.join(project, 'native/tang-detective') + fs.writeFileSync(path.join(source, 'image.jpg'), 'different-data') + assert.throws(() => inventory(project), /SOURCE_BYTES_CHANGED/) + fs.writeFileSync(path.join(source, 'image.jpg'), 'existing-image') + fs.writeFileSync(path.join(source, 'extra.mp3'), 'audio') + assert.throws(() => inventory(project), /SOURCE_FILE_SET_CHANGED/) + fs.unlinkSync(path.join(source, 'extra.mp3')) + fs.symlinkSync(path.join(source, 'image.jpg'), path.join(source, 'linked.jpg')) + assert.throws(() => inventory(project), /SOURCE_SYMLINK_NOT_ALLOWED/) +}) + +test('database config never disables certificate or hostname verification', t => { + const server = fs.mkdtempSync(path.join(os.tmpdir(), 'tang-cos-db-test-')) + t.after(() => fs.rmSync(server, { recursive: true })) + fs.mkdirSync(path.join(server, 'config')) + const values = { hostname: 'db.example.invalid', hostport: '3306', database: 'test', username: 'test', password: 'fake-not-secret', prefix: 'zyt_' } + fs.writeFileSync(path.join(server, 'config/database.php'), Object.entries(values).map(([key, value]) => `env('database.${key}', '${value}')`).join('\n')) + const config = configuredDatabase(server, {}) + assert.equal(config.options.ssl.rejectUnauthorized, true) + assert.equal(config.options.ssl.verifyIdentity, true) + assert.equal(config.options.multipleStatements, false) + fs.writeFileSync(path.join(server, '.env'), 'unparsed') + assert.throws(() => configuredDatabase(server, {}), /SERVER_ENV_REQUIRES_NATIVE_RUNTIME/) +}) + +test('destination rejects insecure/signed URLs and missing credentials', () => { + const config = { bucket: 'example-12345', region: 'ap-guangzhou', access_key: 'test', secret_key: 'test' } + assert.equal(validateCosConfig(config, 'qcloud').baseUrl, 'https://example-12345.cos.ap-guangzhou.myqcloud.com') + for (const domain of ['http://example.invalid', 'https://user:password@example.invalid', 'https://example.invalid/?token=test']) { + assert.throws(() => validateCosConfig({ ...config, domain }, 'qcloud'), /COS_REQUIRES_UNSIGNED_HTTPS_BASE_URL/) + } + assert.throws(() => validateCosConfig(config, 'local'), /CONFIGURED_DRIVER_IS_NOT_COS/) + assert.throws(() => validateCosConfig({ ...config, secret_key: '' }, 'qcloud'), /COS_CREDENTIALS_MISSING/) +}) + +test('object URLs only use namespaced content-addressed keys', () => { + const key = `tang-detective/season-01/media-v1/${hash('image')}.jpg` + assert.equal(objectUrl('https://example.invalid/prefix', key), `https://example.invalid/prefix/${key}`) + assert.throws(() => objectUrl('https://example.invalid', '../unrelated.jpg'), /UNSAFE_OBJECT_KEY/) + assert.throws(() => objectUrl('http://example.invalid', key), /UNSAFE_OBJECT_URL/) +}) + +test('public verification checks complete bytes, MIME, hash and audio Range', async () => { + const bytes = Buffer.from('original-audio') + const entry = { url: 'https://example.invalid/file.mp3', bytes: bytes.length, sha256: hash(bytes), kind: 'audio', contentType: 'audio/mpeg' } + const calls = [] + const verified = await verifyPublicObject(entry, async (url, options) => { + calls.push(options) + assert.equal(options.redirect, 'error') + assert.equal(options.headers?.Authorization, undefined) + return new Response(bytes, options.headers?.Range ? { status: 206, headers: { 'content-range': `bytes 0-13/14` } } + : { status: 200, headers: { 'content-type': 'audio/mpeg' } }) + }) + assert.equal(verified.remoteVerifiedSha256, entry.sha256) + assert.equal(verified.rangeVerified, true) + assert.equal(calls.length, 2) +}) + +test('private objects, oversize bodies, corrupt data and missing Range cannot activate a manifest', async () => { + const entry = { url: 'https://example.invalid/file.mp3', bytes: 4, sha256: hash('good'), kind: 'audio', contentType: 'audio/mpeg' } + await assert.rejects(verifyPublicObject(entry, async () => new Response('denied', { status: 403 })), /PUBLIC_MEDIA_GET_FAILED/) + await assert.rejects(verifyPublicObject(entry, async () => new Response('too-large')), /REMOTE_BODY_TOO_LARGE/) + await assert.rejects(verifyPublicObject(entry, async () => new Response('oops')), /PUBLIC_MEDIA_HASH_MISMATCH/) + await assert.rejects(verifyPublicObject(entry, async () => new Response('good', { headers: { 'content-type': 'audio/mpeg' } })), /PUBLIC_MEDIA_RANGE_FAILED/) +}) + +test('safe errors never leak raw connection or signed-URL text', () => { + assert.equal(safeError({ message: 'mysql://user:password@database/' }), 'REDACTED_OPERATION_ERROR') + assert.equal(safeError({ code: 'HANDSHAKE_SSL_ERROR', message: 'secret' }), 'HANDSHAKE_SSL_ERROR') + assert.equal(safeError({ message: 'https://bucket/?secret=test' }), 'REDACTED_OPERATION_ERROR') +}) + +test('uploader only creates missing objects with checksums and never changes permissions', async () => { + const body = Buffer.from('good') + const entry = { bytes: body.length, sha256: hash(body), objectKey: `tang-detective/season-01/media-v1/${hash(body)}.jpg`, contentType: 'image/jpeg' } + const destination = { bucket: 'example-12345', region: 'ap-guangzhou' } + let put = null + const cos = { headObject: async () => { throw { statusCode: 404 } }, putObject: async params => { put = params } } + const result = await uploadObject(cos, destination, entry, body) + assert.equal(result.action, 'uploaded') + assert.equal(put.Headers['x-cos-forbid-overwrite'], 'true') + assert.equal(put.Headers['x-cos-meta-sha256'], entry.sha256) + assert.equal(put.Headers['Content-MD5'], 'dV+FwnI7s5OBxzeaYEFg2A==') + assert.equal(put.ACL, undefined) + assert.equal(put.Headers['x-cos-acl'], undefined) + assert.equal(put.ContentType, 'image/jpeg') +}) + +test('uploader reuses exact owned objects; mismatches and denied HEAD never trigger writes', async () => { + const entry = { bytes: 4, sha256: hash('good'), objectKey: 'test', contentType: 'image/jpeg' } + let writes = 0 + const cos = { headObject: async () => ({ headers: { 'content-length': '4', 'x-cos-meta-sha256': hash('good') } }), putObject: async () => { writes++ } } + assert.equal((await uploadObject(cos, {}, entry, Buffer.from('good'))).action, 'reused-identical') + cos.headObject = async () => ({ headers: { 'content-length': '4', 'x-cos-meta-sha256': hash('evil') } }) + await assert.rejects(uploadObject(cos, {}, entry, Buffer.from('good')), /EXISTING_OBJECT_NOT_OWNED_OR_DIFFERENT/) + cos.headObject = async () => { throw { statusCode: 403 } } + await assert.rejects(uploadObject(cos, {}, entry, Buffer.from('good'))) + assert.equal(writes, 0) +}) + +test('database deadline aborts a stalled task-owned operation and clears its timer', async () => { + let aborts = 0 + await assert.rejects(withDeadline(new Promise(() => {}), 5, () => { aborts++ }, 'DATABASE_QUERY_TIMEOUT'), /DATABASE_QUERY_TIMEOUT/) + assert.equal(aborts, 1) + assert.equal(await withDeadline(Promise.resolve('done'), 5, () => { aborts++ }, 'DATABASE_QUERY_TIMEOUT'), 'done') + assert.equal(aborts, 1) +}) + +const fakeConfig = () => ({ bucket: 'example-12345', region: 'ap-guangzhou', baseUrl: 'https://example.invalid', + access_key: 'FAKE_ACCESS_MUST_STAY_IN_MEMORY', secret_key: 'FAKE_SECRET_MUST_STAY_IN_MEMORY' }) +const readReceipt = project => JSON.parse(fs.readFileSync(path.join(project, 'build/tang-detective-cos-upload-receipt.json'))) + +test('a configuration failure creates a fresh failed attempt and archives previous success', async t => { + const project = fixture(t) + const previous = { complete: true, runId: 'previous-run', objects: [] } + fs.writeFileSync(path.join(project, 'build/tang-detective-cos-upload-receipt.json'), JSON.stringify(previous)) + await assert.rejects(main('upload', { project, log: () => {}, readConfig: async () => { throw { code: 'HANDSHAKE_SSL_ERROR' } } })) + const latest = readReceipt(project) + assert.equal(latest.complete, false) + assert.notEqual(latest.runId, previous.runId) + assert.equal(latest.phase, 'configuration') + assert.equal(latest.error, 'HANDSHAKE_SSL_ERROR') + assert.deepEqual(latest.objects, []) + const archived = fs.readdirSync(path.join(project, 'build/tang-detective-cos-upload-attempts')) + assert.equal(archived.length, 2) + assert.equal(fs.existsSync(path.join(project, 'build/tang-detective-cos-manifest.json')), false) +}) + +test('SDK initialization failure is recorded without exposing credentials', async t => { + const project = fixture(t) + await assert.rejects(main('upload', { project, readConfig: async () => fakeConfig(), log: () => {}, + createCos: () => { throw new Error('signed-url-or-secret-detail') } })) + const latest = readReceipt(project) + assert.equal(latest.complete, false) + assert.equal(latest.phase, 'sdk-initialization') + assert.equal(latest.error, 'REDACTED_OPERATION_ERROR') + assert.equal(JSON.stringify(latest).includes('MUST_STAY_IN_MEMORY'), false) +}) + +test('lost PUT response persists unknown outcome and only performs read-only reconciliation', async t => { + const project = fixture(t) + let puts = 0 + let heads = 0 + await assert.rejects(main('upload', { project, readConfig: async () => fakeConfig(), log: () => {}, createCos: () => ({ + headObject: async () => { heads++; throw { statusCode: heads === 1 ? 404 : 403 } }, + putObject: async () => { + puts++ + assert.equal(readReceipt(project).objects[0].uploaded, 'unknown') + throw { code: 'ETIMEDOUT' } + }, + }) })) + const latest = readReceipt(project) + assert.equal(latest.complete, false) + assert.equal(latest.objects[0].uploaded, 'unknown') + assert.equal(heads, 2) + assert.equal(puts, 1) + assert.equal(fs.existsSync(path.join(project, 'build/tang-detective-cos-manifest.json')), false) +}) + +test('only a fully verified upload writes the activation manifest; no secret values reach artifacts', async t => { + const project = fixture(t) + await main('upload', { project, readConfig: async () => fakeConfig(), log: () => {}, createCos: () => ({ + headObject: async () => { throw { statusCode: 404 } }, putObject: async () => {}, + }), verify: async entry => ({ remoteVerifiedSha256: entry.sha256, rangeVerified: null, verifiedAt: 'test-only' }) }) + const latest = readReceipt(project) + const manifest = JSON.parse(fs.readFileSync(path.join(project, 'build/tang-detective-cos-manifest.json'))) + assert.equal(latest.complete, true) + assert.equal(manifest.entries.length, 1) + assert.equal(manifest.entries[0].remoteVerifiedSha256, hash('existing-image')) + assert.equal(JSON.stringify([latest, manifest]).includes('MUST_STAY_IN_MEMORY'), false) +}) diff --git a/TUICallKit-Vue3/scripts/test-tang-page-lifecycle.cjs b/TUICallKit-Vue3/scripts/test-tang-page-lifecycle.cjs new file mode 100644 index 0000000..ff9c2bf --- /dev/null +++ b/TUICallKit-Vue3/scripts/test-tang-page-lifecycle.cjs @@ -0,0 +1,280 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') + +const root = path.resolve(__dirname, '..') +const native = path.join(root, 'native/tang-detective') +const adapter = path.join(root, 'native-adapter/tang-detective') +const settle = () => new Promise(resolve => setImmediate(resolve)) + +function fixture(relative, customOptions) { + let page + let finishBoot + let timerId = 0 + const state = { scope: 'account-A', events: [], audio: [], timers: new Map(), updates: 0, saves: [], resets: [], modals: [], redirects: [] } + const boot = new Promise(resolve => { finishBoot = resolve }) + const bridge = { getScope: () => state.scope, open: () => boot, flush: () => state.events.push('flush') } + const storage = { + getProgress: () => ({ completedHotspots: {}, completedChapters: [], lastChapter: 1, collectedMemoryCards: [], comicReaderByChapter: {} }), + saveProgress: value => { state.saves.push({ kind: 'progress', scope: state.scope, value }); return true }, + getSettings: () => ({ fontScale: 'large', sound: true }), + saveSettings: () => true, + getAudioProgress: () => ({}), + saveAudioProgress: value => { state.saves.push({ kind: 'audio', scope: state.scope, value }); return true }, + resetStoryProgress: scope => { state.resets.push(scope); return scope === state.scope }, + } + const beginHandlers = new Set() + const endHandlers = new Set() + const wx = { + env: { USER_DATA_PATH: '' }, + getWindowInfo: () => ({ windowWidth: 812, windowHeight: 375, screenWidth: 812, screenHeight: 375, safeArea: { top: 0, left: 0, right: 812, bottom: 375 } }), + getMenuButtonBoundingClientRect: () => ({ top: 8, bottom: 40, left: 724, right: 804, width: 80, height: 32 }), + pageScrollTo() {}, + reLaunch: value => state.redirects.push(value), + redirectTo: value => state.redirects.push(value), + navigateTo: value => state.redirects.push(value), + showToast: value => state.events.push(value.title), + showModal: value => state.modals.push(value), + onAudioInterruptionBegin: handler => beginHandlers.add(handler), + onAudioInterruptionEnd: handler => endHandlers.add(handler), + offAudioInterruptionBegin: handler => { beginHandlers.delete(handler); state.events.push('unbind-begin') }, + offAudioInterruptionEnd: handler => { endHandlers.delete(handler); state.events.push('unbind-end') }, + createInnerAudioContext() { + const audio = { handlers: {}, duration: 90, currentTime: 0, playbackRate: 1, plays: 0, pauses: 0, destroys: 0, + play() { this.plays += 1 }, pause() { this.pauses += 1 }, stop() {}, + seek(value) { this.currentTime = value }, destroy() { this.destroys += 1 } } + for (const name of ['Canplay', 'Play', 'Pause', 'Stop', 'Waiting', 'TimeUpdate', 'Ended', 'Error', 'Seeked']) { + audio[`on${name}`] = handler => { audio.handlers[name] = handler } + } + state.audio.push(audio) + return audio + }, + } + const cache = new Map() + function capture(options) { + page = { ...options, data: JSON.parse(JSON.stringify(options.data || {})), setData(values) { + state.updates += 1 + for (const [key, value] of Object.entries(values)) { + const parts = key.split('.') + let target = this.data + for (const part of parts.slice(0, -1)) target = target[part] || (target[part] = {}) + target[parts.at(-1)] = value + } + } } + } + let register + function load(filename, isPage = false) { + filename = path.resolve(filename) + if (filename.endsWith('/utils/storage.js')) return storage + if (filename.endsWith('/utils/platformBridge.js')) return bridge + if (cache.has(filename)) return cache.get(filename).exports + const module = { exports: {} } + cache.set(filename, module) + let source = fs.readFileSync(filename, 'utf8') + if (isPage) source = source.replace(/^Page\(\{/m, 'registerTangPage({') + vm.runInNewContext(source, { + module, exports: module.exports, Page: capture, registerTangPage: register, wx, + getCurrentPages: () => [page], + setTimeout: callback => { const id = ++timerId; state.timers.set(id, callback); return id }, + clearTimeout: id => { state.timers.delete(id); state.events.push(`clear:${id}`) }, + require(specifier) { + let dependency = path.resolve(path.dirname(filename), specifier) + if (!path.extname(dependency)) dependency += '.js' + // Catalog uses the final route adapter; all chapter/player helpers and + // data are real original modules, with only platform I/O mocked. + if (!fs.existsSync(dependency) && dependency.startsWith(adapter + path.sep)) { + dependency = path.join(native, path.relative(adapter, dependency)) + } + return load(dependency) + }, + }, { filename }) + return module.exports + } + register = load(path.join(adapter, 'utils/tangPage.js')) + if (customOptions) register(customOptions(state)) + else load(path.join(relative.startsWith('pages/catalog/') ? adapter : native, relative), true) + return { page, state, bridge, finishBoot, beginHandlers, endHandlers } +} + +async function open(f, query = {}) { + f.page.onLoad(query) + f.page.onShow() + f.page.onReady() + f.finishBoot() + await settle() +} + +test('deferred lifecycle delivers onLoad -> onShow -> onReady once and guards custom onX events', async () => { + const f = fixture(null, state => ({ + onLoad() { state.events.push('load') }, onShow() { state.events.push('show') }, onReady() { state.events.push('ready') }, + onAudioTimeUpdate() { state.events.push('audio-event') }, + })) + f.page.onLoad({}) + f.page.onShow() + f.page.onReady() + f.page.onAudioTimeUpdate() + f.page.onHide() + f.page.onShow() + f.finishBoot() + await settle() + assert.deepEqual(f.state.events, ['flush', 'load', 'show', 'ready']) + f.page.onReady() + f.page.onAudioTimeUpdate() + assert.equal(f.state.events.at(-1), 'audio-event') + f.page.onHide() + const count = f.state.events.length + f.page.onAudioTimeUpdate() + assert.equal(f.state.events.length, count) + f.page.onShow() + await settle() + assert.equal(f.state.events.filter(item => item === 'ready').length, 1) + f.state.scope = 'account-B' + f.page.onAudioTimeUpdate() + assert.equal(f.state.events.filter(item => item === 'audio-event').length, 1) + assert.equal(f.state.redirects.length, 1) +}) + +for (const [directory, pageId] of [['package-audio-c01-a', 'S01-C01-P01'], ['package-audio-c01-b', 'S01-C01-P05']]) { + test(`${directory}: real onReady waits for _page and unload clears both timers, context, and listeners`, async () => { + const f = fixture(`${directory}/pages/player/player.js`) + f.page.onLoad({ pageId }) + f.page.onShow() + f.page.onReady() + assert.equal(f.state.audio.length, 0) + f.finishBoot() + await settle() + assert.equal(f.page.data.audioReady, true) + assert.equal(f.state.audio.length, 1) + const context = f.state.audio[0] + f.page.onReady() + assert.equal(f.state.audio.length, 1) + f.page.requestSeek(10) + f.page.beginPauseLock(context) + assert.equal(f.state.timers.size, 2) + const staleTimers = [...f.state.timers.values()] + const staleAudio = Object.values(context.handlers) + f.page.onUnload() + assert.equal(context.destroys, 1) + assert.equal(f.state.timers.size, 0) + assert.equal(f.beginHandlers.size, 0) + assert.equal(f.endHandlers.size, 0) + assert.equal(f.page.audioContext, null) + const updates = f.state.updates + for (const callback of [...staleTimers, ...staleAudio]) callback() + assert.equal(context.plays, 0) + assert.equal(f.state.updates, updates) + f.page.onUnload() + assert.equal(context.destroys, 1) + }) + + test(`${directory}: hidden boot never initializes audio until return; changed-account hide retires old audio`, async () => { + const f = fixture(`${directory}/pages/player/player.js`) + f.page.onLoad({ pageId }) + f.page.onShow() + f.page.onReady() + f.page.onHide() + f.finishBoot() + await settle() + assert.equal(f.state.audio.length, 0) + f.page.onShow() + await settle() + assert.equal(f.page.data.audioReady, true) + const context = f.state.audio[0] + f.page.togglePlayback() + assert.equal(context.plays, 1) + f.state.scope = 'account-B' + f.page.onHide() + assert.ok(context.pauses >= 1) + assert.equal(context.destroys, 1) + assert.equal(f.state.timers.size, 0) + assert.equal(f.beginHandlers.size + f.endHandlers.size, 0) + const updates = f.state.updates + for (const callback of Object.values(context.handlers)) callback() + assert.equal(context.plays, 1) + assert.equal(f.state.updates, updates) + f.state.scope = 'account-A' + f.page.onShow() + await settle() + assert.equal(f.state.audio.length, 1) + assert.equal(f.state.redirects.length, 1) + f.page.onUnload() + assert.equal(context.destroys, 1) + }) +} + +test('real chapter unload destroys audio and stale callbacks cannot save or update', async () => { + const f = fixture('package-game/pages/chapter/chapter.js') + await open(f, { chapter: 1 }) + f.page.createAudioContext({ src: '/example.mp3', progressKey: 'page:S01-C01-P01', durationSeconds: 40 }) + const context = f.state.audio[0] + const staleAudio = Object.values(context.handlers) + f.page.onUnload() + assert.equal(context.destroys, 1) + assert.equal(f.page.audioContext, null) + assert.equal(f.state.saves.filter(item => item.kind === 'audio').length, 1) + const updates = f.state.updates + const saves = f.state.saves.length + for (const callback of staleAudio) callback() + assert.equal(f.state.updates, updates) + assert.equal(f.state.saves.length, saves) + assert.equal(context.plays, 0) +}) + +test('real chapter account-change hide cleans resources without writing previous audio progress into next account', async () => { + const f = fixture('package-game/pages/chapter/chapter.js') + await open(f, { chapter: 1 }) + f.page.createAudioContext({ src: '/example.mp3', progressKey: 'page:S01-C01-P01', durationSeconds: 40 }) + const context = f.state.audio[0] + f.state.scope = 'account-B' + f.page.onHide() + assert.equal(context.destroys, 1) + assert.equal(f.page.audioContext, null) + assert.equal(f.state.saves.filter(item => item.scope === 'account-B').length, 0) + context.handlers.Ended() + assert.equal(f.state.saves.filter(item => item.scope === 'account-B').length, 0) +}) + +test('unload before hydration prevents late onLoad/onShow/onReady and playback', async () => { + const f = fixture('package-audio-c01-a/pages/player/player.js') + f.page.onLoad({ pageId: 'S01-C01-P01' }) + f.page.onShow() + f.page.onReady() + f.page.onUnload() + f.finishBoot() + await settle() + assert.equal(f.state.audio.length, 0) + assert.equal(f.beginHandlers.size, 0) +}) + +test('catalog reset requires unchanged account and the same visible page lifetime', async () => { + for (const transition of ['none', 'account', 'hidden', 'returned', 'unloaded']) { + const f = fixture('pages/catalog/catalog.js') + await open(f) + f.page.restartStory() + assert.equal(f.state.modals.length, 1) + if (transition === 'account') f.state.scope = 'account-B' + if (transition === 'hidden' || transition === 'returned') f.page.onHide() + if (transition === 'returned') { f.page.onShow(); await settle() } + if (transition === 'unloaded') f.page.onUnload() + f.state.modals[0].success({ confirm: true }) + assert.deepEqual(f.state.resets, transition === 'none' ? ['account-A'] : [], transition) + assert.equal(f.state.redirects.length, transition === 'none' ? 1 : 0, transition) + } +}) + +test('cleanup permission ends synchronously even when the original unload throws', async () => { + const f = fixture(null, state => ({ + onUnload() { this.clearTimers(); throw new Error('cleanup failed') }, + clearTimers() { state.events.push('clear') }, + onAudioTimeUpdate() { state.events.push('late-event') }, + })) + await open(f) + assert.throws(() => f.page.onUnload(), /cleanup failed/) + assert.equal(f.page.__tangCleaning, false) + assert.deepEqual(f.state.events, ['clear', 'flush']) + f.page.clearTimers() + f.page.onAudioTimeUpdate() + assert.deepEqual(f.state.events, ['clear', 'flush']) +}) diff --git a/TUICallKit-Vue3/scripts/test-tang-platform.cjs b/TUICallKit-Vue3/scripts/test-tang-platform.cjs new file mode 100644 index 0000000..af512f7 --- /dev/null +++ b/TUICallKit-Vue3/scripts/test-tang-platform.cjs @@ -0,0 +1,401 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const crypto = require('node:crypto') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const { createPlatformBridge } = require('../native-adapter/tang-detective/utils/platformCore') +const { emptyProgress, projectProgress } = require('../native-adapter/tang-detective/utils/progressContract') +const { sha256Hex } = require('../native-adapter/tang-detective/utils/identityHash') +const clone = value => JSON.parse(JSON.stringify(value)) +function fixture(t, initialToken = 'test-account-A') { + const storage = new Map([['token', initialToken]]) + const calls = [] + const servers = new Map() + const state = { offline: false, expired: false, failWrite: false, loseAck: false, beforeAck: null, saveError: '' } + function server(token) { + if (!servers.has(token)) servers.set(token, { user_id: token.endsWith('B') ? 2 : 1, + schema_version: 1, content_version: 'season-01', revision: 0, story_generation: 0, + progress: emptyProgress() }) + return servers.get(token) + } + const platform = { + getStorageSync: key => clone(storage.get(key) || ''), + setStorageSync(key, value) { if (state.failWrite) throw new Error('quota'); storage.set(key, clone(value)) }, + request(options) { + calls.push(clone({ url: options.url, method: options.method, header: options.header, data: options.data || null })) + queueMicrotask(() => { + if (state.offline) return options.fail({}) + if (state.expired) return options.success({ statusCode: 200, data: { code: -1 } }) + let remote = server(options.header.token) + const reply = (code, data) => options.success({ statusCode: 200, data: { code, data: clone(data) } }) + if (options.url.endsWith('/catalog')) return reply(1, { schema_version: 1, content_version: 'season-01' }) + if (options.url.endsWith('/progress')) return reply(1, remote) + assert.equal(options.method, 'POST') + if (state.saveError) return reply(0, { error_code: state.saveError }) + const body = options.data + assert.deepEqual(Object.keys(body).sort(), ['schema_version', 'content_version', 'base_revision', 'story_generation', 'request_id', 'operation', 'progress'].sort()) + assert.deepEqual(body.progress, projectProgress(body.progress)) + if (remote.lastRequest === body.request_id) return reply(1, remote) + if (remote.revision !== body.base_revision || remote.story_generation !== body.story_generation) return reply(0, { error_code: 'PROGRESS_CONFLICT' }) + remote.progress = clone(body.progress) + remote.revision++ + if (body.operation === 'reset_story') remote.story_generation++ + remote.lastRequest = body.request_id + if (state.beforeAck) { const callback = state.beforeAck; state.beforeAck = null; callback() } + if (state.loseAck) { state.loseAck = false; return options.fail({}) } + reply(1, remote) + }) + }, + } + const bridge = createPlatformBridge(platform, { apiBaseUrl: 'https://example.invalid/' }) + t.after(() => bridge.dispose()) + return { bridge, platform, storage, calls, state, server } +} +function advance(bridge, page = 'S01-C01-P02') { + return { ...bridge.getProgress(), lastChapter: 1, + comicReaderByChapter: { 'S01-C01': { currentPageId: page, completedEventIds: [], chapterFinished: false } }, + completedHotspots: { 'S01-C01': [] }, lastPageId: page } +} +test('SHA-256 uses the original portable implementation', () => { + for (const value of ['', 'abc', '测试-token']) assert.equal(sha256Hex(value), crypto.createHash('sha256').update(value).digest('hex')) +}) +test('wire projection excludes story text, health answers, credentials and invalid IDs', () => { + const projected = projectProgress({ ...emptyProgress(), healthAnswer: 'secret', token: 'secret', + memoryCardSnapshots: { secret: 'story text' }, lastChapter: 100, + collectedMemoryCards: ['S01-C01-MC01', 'illegal'], + comicReaderByChapter: { 'S01-C01': { currentPageId: 'S01-C01-P08', completedEventIds: ['S01-H02'], chapterFinished: true } } }) + assert.equal(projected.lastChapter, 1) + assert.equal(projected.comicReaderByChapter['S01-C01'].currentPageId, 'S01-C01-P03') + assert.equal(projected.comicReaderByChapter['S01-C01'].chapterFinished, false) + assert.deepEqual(projected.collectedMemoryCards, ['S01-C01-MC01']) + assert.ok(!JSON.stringify(projected).includes('secret')) +}) +test('guest can read locally without any API calls, and guest state is not uploaded after login', async t => { + const f = fixture(t, '') + await f.bridge.open() + assert.equal(f.bridge.saveProgress(advance(f.bridge)), true) + await f.bridge.flush() + assert.equal(f.calls.length, 0) + f.storage.set('token', 'test-account-A') + await f.bridge.open() + assert.equal(f.bridge.getProgress().lastPageId, '') + assert.equal(f.calls.filter(c => c.method === 'POST').length, 0) +}) +test('authenticated save uses existing token header, JSON, whitelist and own revision', async t => { + const f = fixture(t) + await f.bridge.open() + f.bridge.saveProgress({ ...advance(f.bridge), memoryCardSnapshots: { a: 'stay local' }, answer: 'stay local' }) + await f.bridge.flush() + const post = f.calls.find(c => c.method === 'POST') + assert.equal(post.header.token, 'test-account-A') + assert.equal(post.header['content-type'], 'application/json') + assert.equal(post.data.base_revision, 0) + assert.equal(post.data.progress.lastPageId, 'S01-C01-P02') + assert.ok(!JSON.stringify(post.data).includes('stay local')) + assert.equal(f.bridge.getStatus(), 'synced') + assert.equal(f.bridge.getProgress().answer, 'stay local') + for (const [key, value] of f.storage) if (key !== 'token') assert.ok(!JSON.stringify([key, value]).includes('test-account-A')) +}) +test('offline edits survive and retry when API becomes available', async t => { + const f = fixture(t) + f.state.offline = true + await f.bridge.open() + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(f.calls.filter(c => c.method === 'POST').length, 0) + f.state.offline = false + await f.bridge.open(true) + assert.equal(f.bridge.getStatus(), 'synced') + assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P02') +}) +test('login expiration never silently claims synchronization', async t => { + const f = fixture(t) + f.state.expired = true + await f.bridge.open() + assert.equal(f.bridge.getStatus(), 'auth-expired') + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(f.calls.filter(c => c.method === 'POST').length, 0) +}) +test('account changes isolate progress and reject delayed writes from the previous page', async t => { + const f = fixture(t) + await f.bridge.open() + const old = advance(f.bridge) + f.bridge.saveProgress(old) + await f.bridge.flush() + f.storage.set('token', 'test-account-B') + await f.bridge.open() + assert.equal(f.bridge.getProgress().lastPageId, '') + assert.equal(f.bridge.saveProgress(old), false) + assert.equal(f.server('test-account-B').revision, 0) +}) +test('late HTTP response after account switch does not hydrate the new account', async t => { + const f = fixture(t) + const first = f.bridge.open() + f.storage.set('token', 'test-account-B') + const second = f.bridge.open() + await Promise.all([first, second]) + assert.equal(f.bridge.getProgress().lastPageId, '') + assert.equal(f.bridge.getStatus(), 'synced') + assert.ok(f.bridge.getScope().endsWith(sha256Hex('test-account-B'))) +}) +test('conflict keeps both versions; explicit cloud choice takes a recoverable backup', async t => { + const f = fixture(t) + await f.bridge.open() + f.server('test-account-A').revision = 4 + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(f.bridge.getStatus(), 'conflict') + assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02') + assert.equal(await f.bridge.resolveConflict('cloud', f.bridge.getConflictContext()), true) + assert.equal(f.bridge.getProgress().lastPageId, '') + assert.ok([...f.storage.keys()].some(key => key.endsWith(':conflict-backup'))) +}) +test('explicit local conflict resolution uses the new server revision', async t => { + const f = fixture(t) + await f.bridge.open() + f.server('test-account-A').revision = 2 + f.server('test-account-A').story_generation = 1 + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(await f.bridge.resolveConflict('local', f.bridge.getConflictContext()), true) + const last = f.calls.filter(c => c.method === 'POST').at(-1) + assert.equal(last.data.base_revision, 2) + assert.equal(last.data.story_generation, 1) +}) +test('lost response retries the same request ID and does not double increment', async t => { + const f = fixture(t) + await f.bridge.open() + f.state.loseAck = true + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(f.bridge.getStatus(), 'offline') + await f.bridge.open(true) + const posts = f.calls.filter(c => c.method === 'POST') + assert.equal(posts.length, 2) + assert.equal(posts[0].data.request_id, posts[1].data.request_id) + assert.equal(f.server('test-account-A').revision, 1) + assert.equal(f.bridge.getStatus(), 'synced') +}) +test('new edits made while saving are queued without being overwritten by an old response', async t => { + const f = fixture(t) + await f.bridge.open() + f.state.beforeAck = () => f.bridge.saveProgress(advance(f.bridge, 'S01-C01-P03')) + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P03') + await f.bridge.flush() + assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P03') +}) +test('reset uses an empty story and preserves valid unsynced card IDs', async t => { + const f = fixture(t) + await f.bridge.open() + f.bridge.saveProgress({ ...f.bridge.getProgress(), ...emptyProgress(), collectedMemoryCards: ['S01-C01-MC01'] }, true) + await f.bridge.flush() + const post = f.calls.find(c => c.method === 'POST') + assert.equal(post.data.operation, 'reset_story') + assert.deepEqual(post.data.progress.collectedMemoryCards, ['S01-C01-MC01']) + assert.deepEqual(post.data.progress.comicReaderByChapter, {}) + assert.equal(f.server('test-account-A').story_generation, 1) +}) +test('reset requested during an in-flight replace is not dropped', async t => { + const f = fixture(t) + await f.bridge.open() + f.state.beforeAck = () => f.bridge.saveProgress({ ...f.bridge.getProgress(), ...emptyProgress() }, true) + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + await f.bridge.flush() + assert.equal(f.calls.filter(c => c.method === 'POST').at(-1).data.operation, 'reset_story') +}) +test('storage errors stop cloud writes and are reported truthfully', async t => { + const f = fixture(t) + await f.bridge.open() + f.state.failWrite = true + assert.equal(f.bridge.saveProgress(advance(f.bridge)), false) + await f.bridge.flush() + assert.equal(f.bridge.getStatus(), 'storage-error') + assert.equal(f.calls.filter(c => c.method === 'POST').length, 0) +}) +test('settings/audio remain local and scoped', async t => { + const f = fixture(t) + await f.bridge.open() + f.bridge.writeLocal('settings', { fontScale: 'xlarge' }) + f.bridge.writeLocal('audio', { page: 200 }) + assert.equal(f.calls.filter(c => c.method === 'POST').length, 0) + f.storage.set('token', 'test-account-B') + await f.bridge.open() + assert.deepEqual(f.bridge.readLocal('audio', {}), {}) +}) + +test('a renewed token recovers the authenticated user local unsynced queue', async t => { + const f = fixture(t) + await f.bridge.open() + f.state.offline = true + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + f.storage.set('token', 'renewed-test-account-A') + f.state.offline = false + await f.bridge.open() + assert.equal(f.bridge.getStatus(), 'synced') + assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02') + assert.equal(f.server('renewed-test-account-A').revision, 1) +}) + +test('cloud hydration does not claim local persistence when storage is full', async t => { + const f = fixture(t) + f.state.failWrite = true + await f.bridge.open() + assert.equal(f.bridge.getStatus(), 'storage-error') +}) + +test('permanent contract failure is not automatically resubmitted by page hide or new edits', async t => { + const f = fixture(t) + await f.bridge.open() + f.state.saveError = 'INVALID_REQUEST' + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(f.bridge.getStatus(), 'sync-error') + f.bridge.saveProgress(advance(f.bridge, 'S01-C01-P03')) + await f.bridge.flush() + await f.bridge.open() + assert.equal(f.calls.filter(c => c.method === 'POST').length, 1) +}) + +test('review regression: offline reset then new reading sends reset followed by the new progress', async t => { + const f = fixture(t) + f.state.offline = true + await f.bridge.open() + f.bridge.saveProgress({ ...f.bridge.getProgress(), ...emptyProgress() }, true) + f.bridge.saveProgress(advance(f.bridge)) + f.state.offline = false + await f.bridge.open(true) + assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02') + await f.bridge.flush() + const posts = f.calls.filter(c => c.method === 'POST') + assert.deepEqual(posts.map(c => c.data.operation), ['reset_story', 'replace']) + assert.equal(posts[1].data.story_generation, 1) + assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P02') + assert.equal(f.bridge.getStatus(), 'synced') +}) + +test('review regression: queue persistence failure releases in-flight lock and can recover', async t => { + const f = fixture(t) + await f.bridge.open() + assert.equal(f.bridge.saveProgress(advance(f.bridge)), true) + f.state.failWrite = true + await f.bridge.flush() + assert.equal(f.bridge.getStatus(), 'storage-error') + assert.equal(f.calls.filter(c => c.method === 'POST').length, 0) + f.state.failWrite = false + await f.bridge.open(true) + await f.bridge.flush() + assert.equal(f.bridge.getStatus(), 'synced') + assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P02') +}) + +test('review regression: conflict confirmation is bound to account and exact local/conflict revision', async t => { + const f = fixture(t) + await f.bridge.open() + f.server('test-account-A').revision = 2 + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + const oldContext = f.bridge.getConflictContext() + f.bridge.saveProgress(advance(f.bridge, 'S01-C01-P03')) + assert.equal(await f.bridge.resolveConflict('cloud', oldContext), false) + assert.equal(await f.bridge.resolveConflict('cloud'), false) + f.storage.set('token', 'test-account-B') + await f.bridge.open() + f.server('test-account-B').revision = 2 + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(f.bridge.getStatus(), 'conflict') + assert.equal(await f.bridge.resolveConflict('cloud', oldContext), false) + assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02') +}) + +test('review regression: reset helper rejects missing or stale confirmation scope', async t => { + const f = fixture(t) + await f.bridge.open() + const oldScope = f.bridge.getScope() + const sandbox = { module: { exports: {} }, require: name => name === './platformBridge' ? f.bridge : { emptyProgress } } + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../native-adapter/tang-detective/utils/storage.js'), 'utf8'), sandbox) + const reset = sandbox.module.exports.resetStoryProgress + f.storage.set('token', 'test-account-B') + await f.bridge.open() + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(reset(), false) + assert.equal(reset(oldScope), false) + assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02') + assert.equal(f.server('test-account-B').story_generation, 0) + assert.equal(reset(f.bridge.getScope()), true) + await f.bridge.flush() + assert.equal(f.server('test-account-B').story_generation, 1) +}) + +function pageFixture() { + let finishBoot + let scope = 'account-A' + let page + const events = [] + const boot = new Promise(resolve => { finishBoot = resolve }) + const bridge = { getScope: () => scope, open: () => boot, flush: () => events.push('flush') } + const sandbox = { module: { exports: {} }, require: () => bridge, + Page: options => { page = { ...options, data: { ...options.data }, setData(data) { Object.assign(this.data, data) } } }, + wx: { reLaunch: () => events.push('reLaunch'), showToast: () => events.push('error') } } + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../native-adapter/tang-detective/utils/tangPage.js'), 'utf8'), sandbox) + sandbox.module.exports({ data: { value: 1 }, onLoad() { events.push('load') }, onShow() { events.push('show') }, + onHide() { events.push('hide') }, onUnload() { events.push('unload') }, click() { events.push('click') } }) + return { page, events, finishBoot, changeAccount: () => { scope = 'account-B' } } +} +const settlePage = () => new Promise(resolve => setImmediate(resolve)) +test('native lifecycle waits for account hydration and blocks pre-boot interaction', async () => { + const f = pageFixture() + f.page.onLoad({}) + f.page.onShow() + f.page.click() + assert.deepEqual(f.events, []) + assert.equal(f.page.data.tangBootPending, true) + f.finishBoot() + await settlePage() + assert.deepEqual(f.events, ['load', 'show']) + assert.equal(f.page.data.tangBootPending, false) + f.page.click() + assert.equal(f.events.at(-1), 'click') +}) +test('native page hidden during boot initializes only on return, then cleans up', async () => { + const f = pageFixture() + f.page.onLoad({}) + f.page.onShow() + f.page.onHide() + f.finishBoot() + await settlePage() + assert.deepEqual(f.events, ['flush']) + f.page.onShow() + await settlePage() + assert.deepEqual(f.events, ['flush', 'load', 'show']) + f.page.onUnload() + f.page.click() + assert.deepEqual(f.events.slice(-2), ['unload', 'flush']) +}) +test('native page unloaded before HTTP completion cannot start late playback/initialization', async () => { + const f = pageFixture() + f.page.onLoad({}) + f.page.onShow() + f.page.onUnload() + f.finishBoot() + await settlePage() + assert.deepEqual(f.events, ['flush']) +}) +test('native event after host account changes returns home before old game mutation', async () => { + const f = pageFixture() + f.page.onLoad({}) + f.page.onShow() + f.finishBoot() + await settlePage() + f.changeAccount() + f.page.click() + assert.equal(f.events.at(-1), 'reLaunch') + assert.ok(!f.events.includes('click')) +}) diff --git a/TUICallKit-Vue3/tongji/tang-detective/index.vue b/TUICallKit-Vue3/tongji/tang-detective/index.vue new file mode 100644 index 0000000..140d7b4 --- /dev/null +++ b/TUICallKit-Vue3/tongji/tang-detective/index.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/TongjiUniApp/build/import-tang-detective-native.mjs b/TongjiUniApp/build/import-tang-detective-native.mjs new file mode 100644 index 0000000..8ad3818 --- /dev/null +++ b/TongjiUniApp/build/import-tang-detective-native.mjs @@ -0,0 +1,30 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { listFiles, sha256 } from './tang-detective-native-plugin.mjs' + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const sourceDirectory = process.argv[2] +if (!sourceDirectory) throw new Error('Usage: node build/import-tang-detective-native.mjs ') +const targetDirectory = path.join(projectRoot, 'native/tang-detective') +const allowedRootFiles = new Set(['app.json', 'app.wxss']) +const allowedDirectories = /^(?:assets|data|utils|pages|package-[a-z0-9-]+)\// +const selectedFiles = listFiles(sourceDirectory).filter(relative => allowedRootFiles.has(relative) || allowedDirectories.test(relative)) +if (!selectedFiles.includes('app.json') || !selectedFiles.includes('pages/home/home.js')) throw new Error('Source is not the expected native Tang Detective program') +const files = selectedFiles.map(relative => { + const bytes = fs.readFileSync(path.join(sourceDirectory, relative)) + const destination = path.join(targetDirectory, relative) + const hash = sha256(bytes) + if (fs.existsSync(destination) && sha256(fs.readFileSync(destination)) !== hash) { + throw new Error(`Existing snapshot differs; refusing to overwrite: ${relative}`) + } + return { path: relative, bytes: bytes.length, sha256: hash } +}) +for (const file of files) { + const destination = path.join(targetDirectory, file.path) + fs.mkdirSync(path.dirname(destination), { recursive: true }) + fs.copyFileSync(path.join(sourceDirectory, file.path), destination) +} +const manifest = { version: 1, excludedRootFiles: ['app.js', 'sitemap.json'], files } +fs.writeFileSync(path.join(projectRoot, 'build/tang-detective-source-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`) +console.log(`Imported ${files.length} byte-preserved native files; no source App or project configuration was imported.`) diff --git a/TongjiUniApp/build/tang-detective-admin-audit-receipt.json b/TongjiUniApp/build/tang-detective-admin-audit-receipt.json new file mode 100644 index 0000000..a631ce4 --- /dev/null +++ b/TongjiUniApp/build/tang-detective-admin-audit-receipt.json @@ -0,0 +1,2541 @@ +{ + "schemaVersion": 1, + "runId": "b9ab703a-49a6-4b6b-ac34-979eca1d4828", + "mode": "audit", + "startedAt": "2026-09-08T09:43:34.697Z", + "phase": "complete", + "complete": true, + "uploadTransport": "authenticated-existing-admin-ui", + "destination": { + "bucket": "gz-1349751149", + "region": "ap-guangzhou", + "baseUrl": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com" + }, + "sourceManifestSha256": "3b203406a31fdbe17c770aeea0bab19755acfedf3fa1595641ee4e15a9712e20", + "bucketPermissionsChanged": false, + "originalFilesChanged": false, + "objects": [ + { + "sourcePath": "assets/characters/female-cook.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 11958, + "sha256": "c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f", + "objectKey": "uploads/images/20260908/20260908172953806b83817.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172953806b83817.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.002Z" + }, + { + "sourcePath": "assets/characters/qin-xiaoman.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 92914, + "sha256": "22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5", + "objectKey": "uploads/images/20260908/2026090817295416f2f2841.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817295416f2f2841.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.005Z" + }, + { + "sourcePath": "assets/characters/lele.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 102681, + "sha256": "807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168", + "objectKey": "uploads/images/20260908/20260908172954a3c700294.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954a3c700294.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.008Z" + }, + { + "sourcePath": "assets/characters/lin-xiulan.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 107355, + "sha256": "56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361", + "objectKey": "uploads/images/20260908/202609081729547cbb37718.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081729547cbb37718.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.009Z" + }, + { + "sourcePath": "assets/characters/tang-shouan.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 108663, + "sha256": "4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89", + "objectKey": "uploads/images/20260908/20260908172954a75561985.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954a75561985.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.139Z" + }, + { + "sourcePath": "assets/characters/tang-mingyuan.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 92880, + "sha256": "5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb", + "objectKey": "uploads/images/20260908/20260908172954fef263003.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954fef263003.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.141Z" + }, + { + "sourcePath": "assets/characters/xiaozhen.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 89720, + "sha256": "d5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb", + "objectKey": "uploads/images/20260908/20260908172954578d88427.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954578d88427.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.256Z" + }, + { + "sourcePath": "package-audio-c01-a/assets/player-art/S01-C01-P02-missing-table-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 97655, + "sha256": "f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0", + "objectKey": "uploads/images/20260908/20260908172954af5606377.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954af5606377.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.294Z" + }, + { + "sourcePath": "assets/characters/qin-zhicheng.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 116799, + "sha256": "7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2", + "objectKey": "uploads/images/20260908/202609081729541a4a74577.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081729541a4a74577.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.298Z" + }, + { + "sourcePath": "assets/characters/zhao-jianguo.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 100165, + "sha256": "1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0", + "objectKey": "uploads/images/20260908/2026090817295420b991364.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817295420b991364.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.331Z" + }, + { + "sourcePath": "package-chapter-05/assets/scenes/gui-xiang-1978.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "objectKey": "uploads/images/20260908/2026090817303974c831720.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303974c831720.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.397Z" + }, + { + "sourcePath": "package-chapter-07/assets/scenes/gui-xiang-1995.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 67246, + "sha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f", + "objectKey": "uploads/images/20260908/20260908173040de54b5498.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173040de54b5498.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.420Z" + }, + { + "sourcePath": "package-chapter-09/assets/scenes/gui-xiang-2001.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 59408, + "sha256": "9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8", + "objectKey": "uploads/images/20260908/20260908173039ad5bc2437.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039ad5bc2437.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.517Z" + }, + { + "sourcePath": "package-chapter-08/assets/scenes/gui-xiang-1998.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 62727, + "sha256": "d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14", + "objectKey": "uploads/images/20260908/2026090817303983bc35301.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303983bc35301.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.517Z" + }, + { + "sourcePath": "package-chapter-11/assets/scenes/gui-xiang-2008.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 53286, + "sha256": "c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5", + "objectKey": "uploads/images/20260908/2026090817303994b4b1401.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303994b4b1401.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.596Z" + }, + { + "sourcePath": "package-chapter-10/assets/scenes/gui-xiang-2003.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 53822, + "sha256": "68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431", + "objectKey": "uploads/images/20260908/2026090817303985ee07761.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303985ee07761.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.634Z" + }, + { + "sourcePath": "package-game/assets/scenes/gui-xiang-2026.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "objectKey": "uploads/images/20260908/20260908173039628910741.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039628910741.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.645Z" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 99232, + "sha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69", + "objectKey": "uploads/images/20260908/202609081718480ee488780.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081718480ee488780.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:35.670Z" + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P01.daa8f04a2627.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 270139, + "sha256": "daa8f04a262767109f3a0621f178e3c58f169507e8ea09fa8d583258cb713282", + "objectKey": "uploads/voice/20260908/1788859559652-e90yvvdk.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859559652-e90yvvdk.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "daa8f04a262767109f3a0621f178e3c58f169507e8ea09fa8d583258cb713282", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:35.907Z" + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P03.0f21daa7eccb.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 244853, + "sha256": "0f21daa7eccbaac9139317e8209570fe025fb2403ea5cc794e12586764365885", + "objectKey": "uploads/voice/20260908/1788859681917-n7wb94nh.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859681917-n7wb94nh.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "0f21daa7eccbaac9139317e8209570fe025fb2403ea5cc794e12586764365885", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:35.936Z" + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P04.ed65c367d761.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 375047, + "sha256": "ed65c367d76125e1404d76bb86faa0629b599f6853a39e58821617f854bfa167", + "objectKey": "uploads/voice/20260908/1788859683117-o8y8c1ap.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859683117-o8y8c1ap.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "ed65c367d76125e1404d76bb86faa0629b599f6853a39e58821617f854bfa167", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:36.041Z" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 105278, + "sha256": "12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a", + "objectKey": "uploads/images/20260908/20260908173039668f11872.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039668f11872.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:36.042Z" + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P02.7774981b1878.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 396572, + "sha256": "7774981b1878f6ad882cb7a4e3f923d67f43c12c2ea068982df1cab63820d8c2", + "objectKey": "uploads/voice/20260908/1788859640935-23ixzy68.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859640935-23ixzy68.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7774981b1878f6ad882cb7a4e3f923d67f43c12c2ea068982df1cab63820d8c2", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:36.042Z" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 91819, + "sha256": "6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80", + "objectKey": "uploads/images/20260908/202609081730407a8a27957.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081730407a8a27957.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:36.107Z" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 120832, + "sha256": "bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce", + "objectKey": "uploads/images/20260908/202609081730409483f1003.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081730409483f1003.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:36.158Z" + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P06.5e69ed4c17f3.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 107344, + "sha256": "5e69ed4c17f3d5c4168546c0b76a6d5bfb13ecbecdd982c5be1c27259b9ce1a4", + "objectKey": "uploads/voice/20260908/1788859685309-bq8hczp2.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859685309-bq8hczp2.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "5e69ed4c17f3d5c4168546c0b76a6d5bfb13ecbecdd982c5be1c27259b9ce1a4", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:36.268Z" + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P05.210005d24e32.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 263661, + "sha256": "210005d24e3276e2689e38b0bb3a9d6b6e900ca3cc5ca874278a0b89a50def6a", + "objectKey": "uploads/voice/20260908/1788859684272-vx02qyha.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859684272-vx02qyha.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "210005d24e3276e2689e38b0bb3a9d6b6e900ca3cc5ca874278a0b89a50def6a", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:36.303Z" + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P07.a68bc0cb8ecc.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 84356, + "sha256": "a68bc0cb8ecc6d53b7c877567d6c21fdd894c914e84c072357116429d05d0cc5", + "objectKey": "uploads/voice/20260908/1788859686361-f4myg1jo.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859686361-f4myg1jo.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "a68bc0cb8ecc6d53b7c877567d6c21fdd894c914e84c072357116429d05d0cc5", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:36.328Z" + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P08.1c44eff9d71f.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 316951, + "sha256": "1c44eff9d71fcd004e53050e0727ee37fa3560509d3579c0e2cd004e80e246ec", + "objectKey": "uploads/voice/20260908/1788859687423-at9k5kro.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859687423-at9k5kro.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "1c44eff9d71fcd004e53050e0727ee37fa3560509d3579c0e2cd004e80e246ec", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:36.413Z" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 120294, + "sha256": "7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750", + "objectKey": "uploads/images/20260908/202609081731073a6bf7270.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731073a6bf7270.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:36.421Z" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 111834, + "sha256": "d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f", + "objectKey": "uploads/images/20260908/2026090817310719bb62047.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817310719bb62047.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:36.427Z" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 96418, + "sha256": "4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31", + "objectKey": "uploads/images/20260908/20260908173107b3c447822.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173107b3c447822.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:36.469Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS002.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6417, + "sha256": "ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe", + "objectKey": "uploads/voice/20260908/1788859705645-v0njwhr6.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859705645-v0njwhr6.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:36.665Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS001.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 19125, + "sha256": "580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233", + "objectKey": "uploads/voice/20260908/1788859688761-rv0vx59b.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859688761-rv0vx59b.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:36.673Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS002-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4797, + "sha256": "aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8", + "objectKey": "uploads/voice/20260908/1788859689813-2u5eyx28.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859689813-2u5eyx28.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:36.685Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS003.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 19053, + "sha256": "3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f", + "objectKey": "uploads/voice/20260908/1788859706721-j2czunoh.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859706721-j2czunoh.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:36.699Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS006.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 12465, + "sha256": "067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88", + "objectKey": "uploads/voice/20260908/1788859709963-w40dseme.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859709963-w40dseme.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:36.900Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS005.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 7749, + "sha256": "6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e", + "objectKey": "uploads/voice/20260908/1788859708857-lgkflk7r.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859708857-lgkflk7r.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:36.904Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS004.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 8505, + "sha256": "e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c", + "objectKey": "uploads/voice/20260908/1788859707781-5eif2sva.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859707781-5eif2sva.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:36.907Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS007.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 12213, + "sha256": "7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c", + "objectKey": "uploads/voice/20260908/1788859710994-9ja52nj4.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859710994-9ja52nj4.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:36.924Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS011.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 15741, + "sha256": "2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb", + "objectKey": "uploads/voice/20260908/1788859715307-zvzm6c52.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859715307-zvzm6c52.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:37.179Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS009.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6993, + "sha256": "5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2", + "objectKey": "uploads/voice/20260908/1788859713116-rtg8ou4j.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859713116-rtg8ou4j.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:37.190Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS008.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 12465, + "sha256": "288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f", + "objectKey": "uploads/voice/20260908/1788859712054-pqgtblz7.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859712054-pqgtblz7.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:37.191Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS010.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 27189, + "sha256": "d371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d", + "objectKey": "uploads/voice/20260908/1788859714260-41ehj12t.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859714260-41ehj12t.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:37.318Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-TE900.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 7713, + "sha256": "d57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1", + "objectKey": "uploads/voice/20260908/1788859718453-7taqslda.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859718453-7taqslda.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:37.404Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS012.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 32697, + "sha256": "33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a", + "objectKey": "uploads/voice/20260908/1788859716357-ddolkq84.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859716357-ddolkq84.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:37.412Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MT000.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4005, + "sha256": "5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e", + "objectKey": "uploads/voice/20260908/1788859717407-joxtjydj.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859717407-joxtjydj.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:37.425Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 103969, + "sha256": "c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552", + "objectKey": "uploads/images/20260908/202609081731074bb984948.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731074bb984948.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:37.445Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 125153, + "sha256": "ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593", + "objectKey": "uploads/images/20260908/202609081731075b3e30712.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731075b3e30712.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:37.619Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128039, + "sha256": "cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d", + "objectKey": "uploads/images/20260908/202609081731072dfe55233.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731072dfe55233.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:37.622Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128391, + "sha256": "892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea", + "objectKey": "uploads/images/20260908/202609081731073a9ad6102.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731073a9ad6102.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:37.683Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 133519, + "sha256": "c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539", + "objectKey": "uploads/images/20260908/20260908173107466700017.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173107466700017.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:37.762Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 99846, + "sha256": "61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541", + "objectKey": "uploads/images/20260908/202609081731078ac2d7004.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731078ac2d7004.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:37.781Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128463, + "sha256": "6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703", + "objectKey": "uploads/images/20260908/20260908173112ff8887242.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112ff8887242.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:37.836Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 110191, + "sha256": "e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c", + "objectKey": "uploads/images/20260908/202609081731076ef7d1167.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731076ef7d1167.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:37.939Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS001.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 25821, + "sha256": "b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43", + "objectKey": "uploads/voice/20260908/1788859719556-4h5g177p.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859719556-4h5g177p.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.000Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS002-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 10557, + "sha256": "ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd", + "objectKey": "uploads/voice/20260908/1788859720601-tm2rratq.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859720601-tm2rratq.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.018Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS003.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 20205, + "sha256": "af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184", + "objectKey": "uploads/voice/20260908/1788859736846-tu8raupk.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859736846-tu8raupk.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.139Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS002.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6489, + "sha256": "c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c", + "objectKey": "uploads/voice/20260908/1788859735790-h24dj5jw.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859735790-h24dj5jw.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.139Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS005-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 5517, + "sha256": "4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e", + "objectKey": "uploads/voice/20260908/1788859739954-0sdopsv6.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859739954-0sdopsv6.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.258Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS004.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 7749, + "sha256": "c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da", + "objectKey": "uploads/voice/20260908/1788859738725-lzs4obsj.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859738725-lzs4obsj.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.285Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS005.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 10053, + "sha256": "46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442", + "objectKey": "uploads/voice/20260908/1788859741040-tnxabkmf.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859741040-tnxabkmf.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.355Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS006.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 13401, + "sha256": "2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5", + "objectKey": "uploads/voice/20260908/1788859742080-q1gq96gr.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859742080-q1gq96gr.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.367Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS007-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6309, + "sha256": "f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0", + "objectKey": "uploads/voice/20260908/1788859743113-kgit8i2r.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859743113-kgit8i2r.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.500Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS007.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6849, + "sha256": "68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038", + "objectKey": "uploads/voice/20260908/1788859744174-pg41290p.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859744174-pg41290p.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.513Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS009.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 20925, + "sha256": "97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed", + "objectKey": "uploads/voice/20260908/1788859746348-qrqomq07.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859746348-qrqomq07.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.664Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS008.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 5373, + "sha256": "d9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7", + "objectKey": "uploads/voice/20260908/1788859745258-2u7m9wc0.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859745258-2u7m9wc0.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.665Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS010.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6165, + "sha256": "49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0", + "objectKey": "uploads/voice/20260908/1788859747502-csqqmkys.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859747502-csqqmkys.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.769Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS011.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 15777, + "sha256": "6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25", + "objectKey": "uploads/voice/20260908/1788859748680-hx5gjcjt.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859748680-hx5gjcjt.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.850Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS013.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 19017, + "sha256": "8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a", + "objectKey": "uploads/voice/20260908/1788859749772-njzzx6f8.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859749772-njzzx6f8.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.858Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS014.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4869, + "sha256": "8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0", + "objectKey": "uploads/voice/20260908/1788859750856-ubrs0bjv.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859750856-ubrs0bjv.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.871Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS015.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 14193, + "sha256": "661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb", + "objectKey": "uploads/voice/20260908/1788859751906-i84qzugn.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859751906-i84qzugn.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:38.975Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MT000.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4077, + "sha256": "374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65", + "objectKey": "uploads/voice/20260908/1788859752961-k632st94.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859752961-k632st94.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:39.076Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-TE900.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6813, + "sha256": "f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993", + "objectKey": "uploads/voice/20260908/1788859754110-nyj2b3le.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859754110-nyj2b3le.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:39.186Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 194945, + "sha256": "feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5", + "objectKey": "uploads/images/20260908/20260908173112365a59120.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112365a59120.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:39.187Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 171496, + "sha256": "cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79", + "objectKey": "uploads/images/20260908/20260908173112353247130.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112353247130.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:39.325Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 199826, + "sha256": "8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727", + "objectKey": "uploads/images/20260908/202609081731127140b3844.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731127140b3844.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:39.330Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 207111, + "sha256": "841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e", + "objectKey": "uploads/images/20260908/20260908173112d30437983.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112d30437983.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:39.341Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 84540, + "sha256": "00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1", + "objectKey": "uploads/images/20260908/20260908173112169de0256.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112169de0256.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:39.448Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 210097, + "sha256": "056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5", + "objectKey": "uploads/images/20260908/202609081731122b3399354.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731122b3399354.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:39.483Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 182868, + "sha256": "787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602", + "objectKey": "uploads/images/20260908/20260908173112a505f4772.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112a505f4772.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:39.516Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 118129, + "sha256": "7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4", + "objectKey": "uploads/images/20260908/20260908173112e49c90827.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112e49c90827.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:39.537Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 83596, + "sha256": "e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30", + "objectKey": "uploads/images/20260908/20260908173112c4da43174.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112c4da43174.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:39.711Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 91939, + "sha256": "c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4", + "objectKey": "uploads/images/20260908/20260908173116162c66104.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116162c66104.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:39.712Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 105099, + "sha256": "d83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505", + "objectKey": "uploads/images/20260908/2026090817311649e2a9550.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817311649e2a9550.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:39.713Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 89468, + "sha256": "70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7", + "objectKey": "uploads/images/20260908/20260908173116a53f24003.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116a53f24003.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:39.714Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 104015, + "sha256": "9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61", + "objectKey": "uploads/images/20260908/20260908173116dbdc29125.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116dbdc29125.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:39.955Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 92724, + "sha256": "25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2", + "objectKey": "uploads/images/20260908/20260908173116813c87413.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116813c87413.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.048Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 113102, + "sha256": "52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef", + "objectKey": "uploads/images/20260908/20260908173116c5c618420.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116c5c618420.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.088Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 147490, + "sha256": "76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22", + "objectKey": "uploads/images/20260908/20260908173116ecae73791.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116ecae73791.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.243Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 156680, + "sha256": "897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb", + "objectKey": "uploads/images/20260908/2026090817311649ffe3535.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817311649ffe3535.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.244Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 93259, + "sha256": "4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a", + "objectKey": "uploads/images/20260908/202609081731162a2021285.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731162a2021285.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.365Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 124380, + "sha256": "c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c", + "objectKey": "uploads/images/20260908/202609081731352d7e82556.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731352d7e82556.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.375Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 159817, + "sha256": "a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8", + "objectKey": "uploads/images/20260908/2026090817313559ab83276.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313559ab83276.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.393Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 98885, + "sha256": "3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d", + "objectKey": "uploads/images/20260908/20260908173116bfec43397.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116bfec43397.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.423Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 146026, + "sha256": "63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca", + "objectKey": "uploads/images/20260908/2026090817313533e949160.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313533e949160.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.506Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190776, + "sha256": "b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f", + "objectKey": "uploads/images/20260908/202609081731357238e1677.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731357238e1677.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.539Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190405, + "sha256": "f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40", + "objectKey": "uploads/images/20260908/20260908173135b00ff3493.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173135b00ff3493.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.577Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 140303, + "sha256": "0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae", + "objectKey": "uploads/images/20260908/20260908173135595af4252.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173135595af4252.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.760Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 181320, + "sha256": "caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75", + "objectKey": "uploads/images/20260908/202609081731352d9fb1212.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731352d9fb1212.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.761Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 150118, + "sha256": "f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469", + "objectKey": "uploads/images/20260908/2026090817313638f109229.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313638f109229.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.762Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 142954, + "sha256": "84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53", + "objectKey": "uploads/images/20260908/20260908173138a61767853.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138a61767853.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.895Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 154740, + "sha256": "408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d", + "objectKey": "uploads/images/20260908/2026090817313660b804375.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313660b804375.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.905Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 150951, + "sha256": "172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567", + "objectKey": "uploads/images/20260908/202609081731350aa1d5788.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731350aa1d5788.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.908Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 152752, + "sha256": "c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b", + "objectKey": "uploads/images/20260908/20260908173138fa0762681.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138fa0762681.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:40.928Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 173413, + "sha256": "81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2", + "objectKey": "uploads/images/20260908/20260908173138191298369.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138191298369.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.029Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 170210, + "sha256": "bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5", + "objectKey": "uploads/images/20260908/2026090817313839b279656.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313839b279656.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.045Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 165311, + "sha256": "42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720", + "objectKey": "uploads/images/20260908/20260908173138dff5a5536.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138dff5a5536.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.048Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 147055, + "sha256": "8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3", + "objectKey": "uploads/images/20260908/20260908173138b2b761219.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138b2b761219.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.172Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157850, + "sha256": "5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4", + "objectKey": "uploads/images/20260908/20260908173138c9d001251.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138c9d001251.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.174Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 174837, + "sha256": "7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a", + "objectKey": "uploads/images/20260908/20260908173139f46863978.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173139f46863978.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.284Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128377, + "sha256": "fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5", + "objectKey": "uploads/images/20260908/20260908173138ec8e94150.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138ec8e94150.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.285Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 130849, + "sha256": "19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5", + "objectKey": "uploads/images/20260908/2026090817313967a307026.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313967a307026.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.335Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 161095, + "sha256": "f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e", + "objectKey": "uploads/images/20260908/202609081731411da1c2125.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731411da1c2125.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.345Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157367, + "sha256": "b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc", + "objectKey": "uploads/images/20260908/20260908173141cf0c01964.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173141cf0c01964.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.405Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 191697, + "sha256": "4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562", + "objectKey": "uploads/images/20260908/20260908173142053ed9914.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142053ed9914.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.437Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 178634, + "sha256": "737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935", + "objectKey": "uploads/images/20260908/2026090817314274cfe2605.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817314274cfe2605.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.494Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 116785, + "sha256": "6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea", + "objectKey": "uploads/images/20260908/202609081731425a0819698.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731425a0819698.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.628Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 163905, + "sha256": "fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111", + "objectKey": "uploads/images/20260908/20260908173142d16d12935.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142d16d12935.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.633Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 138725, + "sha256": "2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743", + "objectKey": "uploads/images/20260908/202609081731429844c1305.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731429844c1305.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.811Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 150476, + "sha256": "59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f", + "objectKey": "uploads/images/20260908/20260908173142346106887.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142346106887.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.812Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 179019, + "sha256": "0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d", + "objectKey": "uploads/images/20260908/20260908173145f82762147.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145f82762147.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.928Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 180813, + "sha256": "41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618", + "objectKey": "uploads/images/20260908/202609081731420bc315230.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731420bc315230.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.935Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 192720, + "sha256": "9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af", + "objectKey": "uploads/images/20260908/202609081731451a0a48902.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731451a0a48902.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:41.992Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 164727, + "sha256": "5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a", + "objectKey": "uploads/images/20260908/20260908173145050809349.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145050809349.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.068Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 139199, + "sha256": "cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff", + "objectKey": "uploads/images/20260908/20260908173145e43cc7774.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145e43cc7774.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.079Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 198741, + "sha256": "2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c", + "objectKey": "uploads/images/20260908/20260908173142ef5784670.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142ef5784670.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.093Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 180695, + "sha256": "5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443", + "objectKey": "uploads/images/20260908/202609081731456267e0864.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731456267e0864.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.125Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 152317, + "sha256": "b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724", + "objectKey": "uploads/images/20260908/2026090817314563ce19085.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817314563ce19085.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.200Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 176826, + "sha256": "9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f", + "objectKey": "uploads/images/20260908/202609081731451e62d8082.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731451e62d8082.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.201Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 170147, + "sha256": "42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8", + "objectKey": "uploads/images/20260908/20260908173145234414957.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145234414957.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.333Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 142107, + "sha256": "be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde", + "objectKey": "uploads/images/20260908/202609081732037cfc44506.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732037cfc44506.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.433Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 145630, + "sha256": "23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9", + "objectKey": "uploads/images/20260908/20260908173145d06c49932.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145d06c49932.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.462Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 189031, + "sha256": "a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd", + "objectKey": "uploads/images/20260908/202609081732034eb7f0368.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732034eb7f0368.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.476Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 201027, + "sha256": "30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55", + "objectKey": "uploads/images/20260908/2026090817314577b3d2877.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817314577b3d2877.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.478Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 161904, + "sha256": "6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446", + "objectKey": "uploads/images/20260908/202609081732039e6701414.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732039e6701414.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.595Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157096, + "sha256": "13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99", + "objectKey": "uploads/images/20260908/2026090817320373f998007.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320373f998007.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.600Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 170560, + "sha256": "840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a", + "objectKey": "uploads/images/20260908/202609081732035f2e17452.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732035f2e17452.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.612Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 205032, + "sha256": "2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0", + "objectKey": "uploads/images/20260908/20260908173203dace72023.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173203dace72023.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.627Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 185485, + "sha256": "3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50", + "objectKey": "uploads/images/20260908/202609081732035c9d61064.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732035c9d61064.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.749Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 95514, + "sha256": "23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51", + "objectKey": "uploads/images/20260908/202609081732032db544840.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732032db544840.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.750Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 120040, + "sha256": "c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5", + "objectKey": "uploads/images/20260908/202609081732037c8583818.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732037c8583818.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.753Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 122506, + "sha256": "112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1", + "objectKey": "uploads/images/20260908/20260908173203e27952820.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173203e27952820.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.859Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 172410, + "sha256": "4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c", + "objectKey": "uploads/images/20260908/2026090817320602c476049.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320602c476049.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.868Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157302, + "sha256": "9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a", + "objectKey": "uploads/images/20260908/20260908173206dae776115.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206dae776115.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.891Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 112557, + "sha256": "b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de", + "objectKey": "uploads/images/20260908/202609081732065676e2434.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732065676e2434.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:42.896Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 167948, + "sha256": "e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a", + "objectKey": "uploads/images/20260908/20260908173206a7ebe7583.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206a7ebe7583.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.017Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 133362, + "sha256": "c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9", + "objectKey": "uploads/images/20260908/20260908173206b42891414.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206b42891414.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.028Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 152941, + "sha256": "1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e", + "objectKey": "uploads/images/20260908/2026090817320615d6c3156.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320615d6c3156.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.029Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 195056, + "sha256": "338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5", + "objectKey": "uploads/images/20260908/2026090817320628b5f1545.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320628b5f1545.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.140Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190911, + "sha256": "6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602", + "objectKey": "uploads/images/20260908/20260908173206cb3721071.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206cb3721071.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.147Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 164550, + "sha256": "695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0", + "objectKey": "uploads/images/20260908/20260908173206946d11690.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206946d11690.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.156Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 153998, + "sha256": "9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76", + "objectKey": "uploads/images/20260908/202609081732067211a5043.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732067211a5043.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.187Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 191222, + "sha256": "2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e", + "objectKey": "uploads/images/20260908/20260908173210dcc904301.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210dcc904301.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.273Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 135599, + "sha256": "a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f", + "objectKey": "uploads/images/20260908/20260908173210f33956967.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210f33956967.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.274Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 201320, + "sha256": "13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b", + "objectKey": "uploads/images/20260908/202609081732103e1737129.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732103e1737129.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.386Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 214867, + "sha256": "2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff", + "objectKey": "uploads/images/20260908/202609081732106acce6348.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732106acce6348.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.399Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 173154, + "sha256": "d4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d", + "objectKey": "uploads/images/20260908/20260908173210052871552.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210052871552.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.420Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 201951, + "sha256": "a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198", + "objectKey": "uploads/images/20260908/202609081732104dda17591.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732104dda17591.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.466Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 179621, + "sha256": "f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc", + "objectKey": "uploads/images/20260908/20260908173210e3e400579.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210e3e400579.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.531Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 184565, + "sha256": "8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d", + "objectKey": "uploads/images/20260908/2026090817321063e243356.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321063e243356.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.538Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 198924, + "sha256": "edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b", + "objectKey": "uploads/images/20260908/202609081732103c91a8938.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732103c91a8938.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.547Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 173212, + "sha256": "6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2", + "objectKey": "uploads/images/20260908/20260908173210a1bfa8588.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210a1bfa8588.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.606Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190877, + "sha256": "899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e", + "objectKey": "uploads/images/20260908/202609081732148dba50220.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732148dba50220.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.683Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 181140, + "sha256": "9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365", + "objectKey": "uploads/images/20260908/20260908173214a4a8d4074.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214a4a8d4074.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.732Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 181882, + "sha256": "b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801", + "objectKey": "uploads/images/20260908/202609081732146ee292192.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732146ee292192.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.752Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 204940, + "sha256": "15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48", + "objectKey": "uploads/images/20260908/2026090817321402ddc8471.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321402ddc8471.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.801Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 192247, + "sha256": "c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd", + "objectKey": "uploads/images/20260908/2026090817321455aa99619.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321455aa99619.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.906Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 172139, + "sha256": "09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c", + "objectKey": "uploads/images/20260908/20260908173214ac7b62068.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214ac7b62068.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.908Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 166223, + "sha256": "d0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d", + "objectKey": "uploads/images/20260908/20260908173214765e24253.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214765e24253.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.929Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 207370, + "sha256": "194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab", + "objectKey": "uploads/images/20260908/202609081732143d4070553.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732143d4070553.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:43.951Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 138672, + "sha256": "2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea", + "objectKey": "uploads/images/20260908/20260908173218201a61275.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173218201a61275.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:44.041Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 166021, + "sha256": "21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a", + "objectKey": "uploads/images/20260908/20260908173218191279663.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173218191279663.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:44.095Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 162103, + "sha256": "b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173", + "objectKey": "uploads/images/20260908/20260908173214896801232.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214896801232.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:44.099Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 189454, + "sha256": "b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b", + "objectKey": "uploads/images/20260908/2026090817321405dc63371.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321405dc63371.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:44.157Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 172654, + "sha256": "3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50", + "objectKey": "uploads/images/20260908/202609081732189a41f9598.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732189a41f9598.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:44.230Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 210429, + "sha256": "98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98", + "objectKey": "uploads/images/20260908/202609081732189ef359918.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732189ef359918.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:44.248Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 96215, + "sha256": "520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113", + "objectKey": "uploads/images/20260908/20260908173218765170829.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173218765170829.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:43:44.269Z" + }, + { + "sourcePath": "package-game/assets/audio/S01-C01-MS007.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 18957, + "sha256": "1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8", + "objectKey": "uploads/voice/20260908/1788859755676-8bbjnyhb.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859755676-8bbjnyhb.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:44.430Z" + }, + { + "sourcePath": "package-game/assets/audio/S01-C01-MS010.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 17421, + "sha256": "628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597", + "objectKey": "uploads/voice/20260908/1788859756823-r37bn776.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859756823-r37bn776.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:43:44.539Z" + } + ], + "mediaManifestSha256": "0062a1edbca7d08cba3c21fc226f419def91d8dfd644f41b9e35c2543ef508a0", + "completedAt": "2026-09-08T09:43:44.540Z" +} diff --git a/TongjiUniApp/build/tang-detective-admin-upload-observations.json b/TongjiUniApp/build/tang-detective-admin-upload-observations.json new file mode 100644 index 0000000..233c301 --- /dev/null +++ b/TongjiUniApp/build/tang-detective-admin-upload-observations.json @@ -0,0 +1,169 @@ +{ + "schemaVersion": 1, + "uploadRunId": "b9ab703a-49a6-4b6b-ac34-979eca1d4828", + "baseUrl": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com", + "imageDirectory": "uploads/images/20260908/", + "audioDirectory": "uploads/voice/20260908/", + "firstImage": "202609081718480ee488780.jpg", + "images": [ + "20260908172953806b83817.jpg", + "20260908172954a3c700294.jpg", + "202609081729547cbb37718.jpg", + "2026090817295416f2f2841.jpg", + "202609081729541a4a74577.jpg", + "20260908172954fef263003.jpg", + "20260908172954a75561985.jpg", + "20260908172954578d88427.jpg", + "2026090817295420b991364.jpg", + "20260908172954af5606377.jpg", + "2026090817303974c831720.jpg", + "20260908173040de54b5498.jpg", + "2026090817303983bc35301.jpg", + "20260908173039ad5bc2437.jpg", + "2026090817303985ee07761.jpg", + "2026090817303994b4b1401.jpg", + "20260908173039628910741.jpg", + "20260908173039668f11872.jpg", + "202609081730407a8a27957.jpg", + "202609081730409483f1003.jpg", + "2026090817310719bb62047.jpg", + "202609081731073a6bf7270.jpg", + "20260908173107b3c447822.jpg", + "202609081731074bb984948.jpg", + "202609081731073a9ad6102.jpg", + "202609081731076ef7d1167.jpg", + "202609081731075b3e30712.jpg", + "202609081731072dfe55233.jpg", + "202609081731078ac2d7004.jpg", + "20260908173107466700017.jpg", + "20260908173112ff8887242.jpg", + "20260908173112e49c90827.jpg", + "20260908173112365a59120.jpg", + "20260908173112d30437983.jpg", + "202609081731127140b3844.jpg", + "20260908173112353247130.jpg", + "20260908173112169de0256.jpg", + "20260908173112a505f4772.jpg", + "202609081731122b3399354.jpg", + "20260908173112c4da43174.jpg", + "2026090817311649e2a9550.jpg", + "20260908173116162c66104.jpg", + "20260908173116a53f24003.jpg", + "202609081731162a2021285.jpg", + "20260908173116813c87413.jpg", + "20260908173116bfec43397.jpg", + "20260908173116dbdc29125.jpg", + "20260908173116c5c618420.jpg", + "2026090817311649ffe3535.jpg", + "20260908173116ecae73791.jpg", + "202609081731352d7e82556.jpg", + "2026090817313559ab83276.jpg", + "2026090817313533e949160.jpg", + "202609081731350aa1d5788.jpg", + "202609081731357238e1677.jpg", + "20260908173135b00ff3493.jpg", + "20260908173135595af4252.jpg", + "202609081731352d9fb1212.jpg", + "2026090817313638f109229.jpg", + "2026090817313660b804375.jpg", + "20260908173138a61767853.jpg", + "20260908173138fa0762681.jpg", + "2026090817313839b279656.jpg", + "20260908173138dff5a5536.jpg", + "20260908173138191298369.jpg", + "20260908173138ec8e94150.jpg", + "20260908173138b2b761219.jpg", + "20260908173138c9d001251.jpg", + "20260908173139f46863978.jpg", + "2026090817313967a307026.jpg", + "202609081731411da1c2125.jpg", + "20260908173141cf0c01964.jpg", + "20260908173142053ed9914.jpg", + "20260908173142ef5784670.jpg", + "2026090817314274cfe2605.jpg", + "202609081731420bc315230.jpg", + "20260908173142d16d12935.jpg", + "202609081731425a0819698.jpg", + "202609081731429844c1305.jpg", + "20260908173142346106887.jpg", + "20260908173145f82762147.jpg", + "202609081731451a0a48902.jpg", + "20260908173145e43cc7774.jpg", + "20260908173145050809349.jpg", + "202609081731456267e0864.jpg", + "202609081731451e62d8082.jpg", + "2026090817314563ce19085.jpg", + "20260908173145234414957.jpg", + "2026090817314577b3d2877.jpg", + "20260908173145d06c49932.jpg", + "202609081732037cfc44506.jpg", + "202609081732034eb7f0368.jpg", + "202609081732039e6701414.jpg", + "2026090817320373f998007.jpg", + "20260908173203dace72023.jpg", + "202609081732035f2e17452.jpg", + "20260908173203e27952820.jpg", + "202609081732032db544840.jpg", + "202609081732035c9d61064.jpg", + "202609081732037c8583818.jpg", + "2026090817320602c476049.jpg", + "20260908173206dae776115.jpg", + "202609081732065676e2434.jpg", + "20260908173206a7ebe7583.jpg", + "20260908173206cb3721071.jpg", + "20260908173206b42891414.jpg", + "2026090817320615d6c3156.jpg", + "20260908173206946d11690.jpg", + "202609081732067211a5043.jpg", + "2026090817320628b5f1545.jpg", + "20260908173210f33956967.jpg", + "20260908173210dcc904301.jpg", + "202609081732104dda17591.jpg", + "202609081732103e1737129.jpg", + "20260908173210052871552.jpg", + "202609081732106acce6348.jpg", + "2026090817321063e243356.jpg", + "202609081732103c91a8938.jpg", + "20260908173210e3e400579.jpg", + "20260908173210a1bfa8588.jpg", + "2026090817321402ddc8471.jpg", + "20260908173214a4a8d4074.jpg", + "202609081732148dba50220.jpg", + "202609081732146ee292192.jpg", + "2026090817321455aa99619.jpg", + "20260908173214765e24253.jpg", + "20260908173214ac7b62068.jpg", + "202609081732143d4070553.jpg", + "2026090817321405dc63371.jpg", + "20260908173214896801232.jpg", + "20260908173218201a61275.jpg", + "20260908173218191279663.jpg", + "202609081732189a41f9598.jpg", + "202609081732189ef359918.jpg", + "20260908173218765170829.jpg" + ], + "audio": [ + "1788859559652-e90yvvdk.mp3", "1788859640935-23ixzy68.mp3", + "1788859681917-n7wb94nh.mp3", "1788859683117-o8y8c1ap.mp3", + "1788859684272-vx02qyha.mp3", "1788859685309-bq8hczp2.mp3", + "1788859686361-f4myg1jo.mp3", "1788859687423-at9k5kro.mp3", + "1788859688761-rv0vx59b.mp3", "1788859689813-2u5eyx28.mp3", + "1788859705645-v0njwhr6.mp3", "1788859706721-j2czunoh.mp3", + "1788859707781-5eif2sva.mp3", "1788859708857-lgkflk7r.mp3", + "1788859709963-w40dseme.mp3", "1788859710994-9ja52nj4.mp3", + "1788859712054-pqgtblz7.mp3", "1788859713116-rtg8ou4j.mp3", + "1788859714260-41ehj12t.mp3", "1788859715307-zvzm6c52.mp3", + "1788859716357-ddolkq84.mp3", "1788859717407-joxtjydj.mp3", + "1788859718453-7taqslda.mp3", "1788859719556-4h5g177p.mp3", + "1788859720601-tm2rratq.mp3", "1788859735790-h24dj5jw.mp3", + "1788859736846-tu8raupk.mp3", "1788859738725-lzs4obsj.mp3", + "1788859739954-0sdopsv6.mp3", "1788859741040-tnxabkmf.mp3", + "1788859742080-q1gq96gr.mp3", "1788859743113-kgit8i2r.mp3", + "1788859744174-pg41290p.mp3", "1788859745258-2u7m9wc0.mp3", + "1788859746348-qrqomq07.mp3", "1788859747502-csqqmkys.mp3", + "1788859748680-hx5gjcjt.mp3", "1788859749772-njzzx6f8.mp3", + "1788859750856-ubrs0bjv.mp3", "1788859751906-i84qzugn.mp3", + "1788859752961-k632st94.mp3", "1788859754110-nyj2b3le.mp3", + "1788859755676-8bbjnyhb.mp3", "1788859756823-r37bn776.mp3" + ] +} diff --git a/TongjiUniApp/build/tang-detective-admin-upload-plan.json b/TongjiUniApp/build/tang-detective-admin-upload-plan.json new file mode 100644 index 0000000..39a1357 --- /dev/null +++ b/TongjiUniApp/build/tang-detective-admin-upload-plan.json @@ -0,0 +1,1990 @@ +{ + "schemaVersion": 1, + "uploadRunId": "b9ab703a-49a6-4b6b-ac34-979eca1d4828", + "sourceManifestSha256": "3b203406a31fdbe17c770aeea0bab19755acfedf3fa1595641ee4e15a9712e20", + "sourceDirectory": "/Users/dagedagededagege/Pictures/xuetang/TongjiUniApp/native/tang-detective", + "stagingDirectory": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging", + "createdAt": "2026-09-08T09:22:38.173Z", + "entries": [ + { + "sourcePath": "assets/characters/female-cook.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 11958, + "sha256": "c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f", + "objectKey": "tang-detective/season-01/media-v1/c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f.jpg", + "stagedName": "tang-20260908-1714-image-001.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-001.jpg", + "observedUrl": null + }, + { + "sourcePath": "assets/characters/lele.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 102681, + "sha256": "807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168", + "objectKey": "tang-detective/season-01/media-v1/807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168.jpg", + "stagedName": "tang-20260908-1714-image-002.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-002.jpg", + "observedUrl": null + }, + { + "sourcePath": "assets/characters/lin-xiulan.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 107355, + "sha256": "56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361", + "objectKey": "tang-detective/season-01/media-v1/56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361.jpg", + "stagedName": "tang-20260908-1714-image-003.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-003.jpg", + "observedUrl": null + }, + { + "sourcePath": "assets/characters/qin-xiaoman.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 92914, + "sha256": "22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5", + "objectKey": "tang-detective/season-01/media-v1/22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5.jpg", + "stagedName": "tang-20260908-1714-image-004.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-004.jpg", + "observedUrl": null + }, + { + "sourcePath": "assets/characters/qin-zhicheng.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 116799, + "sha256": "7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2", + "objectKey": "tang-detective/season-01/media-v1/7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2.jpg", + "stagedName": "tang-20260908-1714-image-005.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-005.jpg", + "observedUrl": null + }, + { + "sourcePath": "assets/characters/tang-mingyuan.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 92880, + "sha256": "5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb", + "objectKey": "tang-detective/season-01/media-v1/5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb.jpg", + "stagedName": "tang-20260908-1714-image-006.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-006.jpg", + "observedUrl": null + }, + { + "sourcePath": "assets/characters/tang-shouan.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 108663, + "sha256": "4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89", + "objectKey": "tang-detective/season-01/media-v1/4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89.jpg", + "stagedName": "tang-20260908-1714-image-007.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-007.jpg", + "observedUrl": null + }, + { + "sourcePath": "assets/characters/xiaozhen.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 89720, + "sha256": "d5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb", + "objectKey": "tang-detective/season-01/media-v1/d5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb.jpg", + "stagedName": "tang-20260908-1714-image-008.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-008.jpg", + "observedUrl": null + }, + { + "sourcePath": "assets/characters/zhao-jianguo.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 100165, + "sha256": "1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0", + "objectKey": "tang-detective/season-01/media-v1/1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0.jpg", + "stagedName": "tang-20260908-1714-image-009.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-009.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-audio-c01-a/assets/player-art/S01-C01-P02-missing-table-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 97655, + "sha256": "f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0", + "objectKey": "tang-detective/season-01/media-v1/f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0.jpg", + "stagedName": "tang-20260908-1714-image-010.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-010.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-05/assets/scenes/gui-xiang-1978.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "objectKey": "tang-detective/season-01/media-v1/bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5.jpg", + "stagedName": "tang-20260908-1714-image-011.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-011.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-07/assets/scenes/gui-xiang-1995.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 67246, + "sha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f", + "objectKey": "tang-detective/season-01/media-v1/a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f.jpg", + "stagedName": "tang-20260908-1714-image-012.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-012.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-08/assets/scenes/gui-xiang-1998.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 62727, + "sha256": "d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14", + "objectKey": "tang-detective/season-01/media-v1/d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14.jpg", + "stagedName": "tang-20260908-1714-image-013.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-013.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-09/assets/scenes/gui-xiang-2001.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 59408, + "sha256": "9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8", + "objectKey": "tang-detective/season-01/media-v1/9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8.jpg", + "stagedName": "tang-20260908-1714-image-014.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-014.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-10/assets/scenes/gui-xiang-2003.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 53822, + "sha256": "68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431", + "objectKey": "tang-detective/season-01/media-v1/68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431.jpg", + "stagedName": "tang-20260908-1714-image-015.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-015.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-11/assets/scenes/gui-xiang-2008.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 53286, + "sha256": "c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5", + "objectKey": "tang-detective/season-01/media-v1/c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5.jpg", + "stagedName": "tang-20260908-1714-image-016.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-016.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-game/assets/scenes/gui-xiang-2026.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "objectKey": "tang-detective/season-01/media-v1/55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7.jpg", + "stagedName": "tang-20260908-1714-image-017.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-017.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 99232, + "sha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69", + "objectKey": "tang-detective/season-01/media-v1/7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69.jpg", + "stagedName": null, + "stagedPath": null, + "observedUrl": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081718480ee488780.jpg" + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P01.daa8f04a2627.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 270139, + "sha256": "daa8f04a262767109f3a0621f178e3c58f169507e8ea09fa8d583258cb713282", + "objectKey": "tang-detective/season-01/media-v1/daa8f04a262767109f3a0621f178e3c58f169507e8ea09fa8d583258cb713282.mp3", + "stagedName": "tang-20260908-1714-audio-001.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-001.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P02.7774981b1878.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 396572, + "sha256": "7774981b1878f6ad882cb7a4e3f923d67f43c12c2ea068982df1cab63820d8c2", + "objectKey": "tang-detective/season-01/media-v1/7774981b1878f6ad882cb7a4e3f923d67f43c12c2ea068982df1cab63820d8c2.mp3", + "stagedName": "tang-20260908-1714-audio-002.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-002.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P03.0f21daa7eccb.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 244853, + "sha256": "0f21daa7eccbaac9139317e8209570fe025fb2403ea5cc794e12586764365885", + "objectKey": "tang-detective/season-01/media-v1/0f21daa7eccbaac9139317e8209570fe025fb2403ea5cc794e12586764365885.mp3", + "stagedName": "tang-20260908-1714-audio-003.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-003.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P04.ed65c367d761.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 375047, + "sha256": "ed65c367d76125e1404d76bb86faa0629b599f6853a39e58821617f854bfa167", + "objectKey": "tang-detective/season-01/media-v1/ed65c367d76125e1404d76bb86faa0629b599f6853a39e58821617f854bfa167.mp3", + "stagedName": "tang-20260908-1714-audio-004.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-004.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 105278, + "sha256": "12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a", + "objectKey": "tang-detective/season-01/media-v1/12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a.jpg", + "stagedName": "tang-20260908-1714-image-018.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-018.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 91819, + "sha256": "6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80", + "objectKey": "tang-detective/season-01/media-v1/6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80.jpg", + "stagedName": "tang-20260908-1714-image-019.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-019.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 120832, + "sha256": "bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce", + "objectKey": "tang-detective/season-01/media-v1/bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce.jpg", + "stagedName": "tang-20260908-1714-image-020.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-020.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P05.210005d24e32.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 263661, + "sha256": "210005d24e3276e2689e38b0bb3a9d6b6e900ca3cc5ca874278a0b89a50def6a", + "objectKey": "tang-detective/season-01/media-v1/210005d24e3276e2689e38b0bb3a9d6b6e900ca3cc5ca874278a0b89a50def6a.mp3", + "stagedName": "tang-20260908-1714-audio-005.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-005.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P06.5e69ed4c17f3.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 107344, + "sha256": "5e69ed4c17f3d5c4168546c0b76a6d5bfb13ecbecdd982c5be1c27259b9ce1a4", + "objectKey": "tang-detective/season-01/media-v1/5e69ed4c17f3d5c4168546c0b76a6d5bfb13ecbecdd982c5be1c27259b9ce1a4.mp3", + "stagedName": "tang-20260908-1714-audio-006.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-006.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P07.a68bc0cb8ecc.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 84356, + "sha256": "a68bc0cb8ecc6d53b7c877567d6c21fdd894c914e84c072357116429d05d0cc5", + "objectKey": "tang-detective/season-01/media-v1/a68bc0cb8ecc6d53b7c877567d6c21fdd894c914e84c072357116429d05d0cc5.mp3", + "stagedName": "tang-20260908-1714-audio-007.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-007.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P08.1c44eff9d71f.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 316951, + "sha256": "1c44eff9d71fcd004e53050e0727ee37fa3560509d3579c0e2cd004e80e246ec", + "objectKey": "tang-detective/season-01/media-v1/1c44eff9d71fcd004e53050e0727ee37fa3560509d3579c0e2cd004e80e246ec.mp3", + "stagedName": "tang-20260908-1714-audio-008.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-008.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 111834, + "sha256": "d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f", + "objectKey": "tang-detective/season-01/media-v1/d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f.jpg", + "stagedName": "tang-20260908-1714-image-021.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-021.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 120294, + "sha256": "7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750", + "objectKey": "tang-detective/season-01/media-v1/7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750.jpg", + "stagedName": "tang-20260908-1714-image-022.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-022.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 96418, + "sha256": "4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31", + "objectKey": "tang-detective/season-01/media-v1/4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31.jpg", + "stagedName": "tang-20260908-1714-image-023.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-023.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS001.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 19125, + "sha256": "580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233", + "objectKey": "tang-detective/season-01/media-v1/580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233.mp3", + "stagedName": "tang-20260908-1714-audio-009.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-009.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS002-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4797, + "sha256": "aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8", + "objectKey": "tang-detective/season-01/media-v1/aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8.mp3", + "stagedName": "tang-20260908-1714-audio-010.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-010.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS002.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6417, + "sha256": "ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe", + "objectKey": "tang-detective/season-01/media-v1/ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe.mp3", + "stagedName": "tang-20260908-1714-audio-011.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-011.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS003.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 19053, + "sha256": "3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f", + "objectKey": "tang-detective/season-01/media-v1/3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f.mp3", + "stagedName": "tang-20260908-1714-audio-012.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-012.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS004.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 8505, + "sha256": "e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c", + "objectKey": "tang-detective/season-01/media-v1/e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c.mp3", + "stagedName": "tang-20260908-1714-audio-013.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-013.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS005.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 7749, + "sha256": "6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e", + "objectKey": "tang-detective/season-01/media-v1/6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e.mp3", + "stagedName": "tang-20260908-1714-audio-014.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-014.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS006.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 12465, + "sha256": "067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88", + "objectKey": "tang-detective/season-01/media-v1/067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88.mp3", + "stagedName": "tang-20260908-1714-audio-015.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-015.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS007.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 12213, + "sha256": "7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c", + "objectKey": "tang-detective/season-01/media-v1/7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c.mp3", + "stagedName": "tang-20260908-1714-audio-016.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-016.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS008.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 12465, + "sha256": "288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f", + "objectKey": "tang-detective/season-01/media-v1/288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f.mp3", + "stagedName": "tang-20260908-1714-audio-017.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-017.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS009.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6993, + "sha256": "5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2", + "objectKey": "tang-detective/season-01/media-v1/5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2.mp3", + "stagedName": "tang-20260908-1714-audio-018.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-018.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS010.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 27189, + "sha256": "d371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d", + "objectKey": "tang-detective/season-01/media-v1/d371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d.mp3", + "stagedName": "tang-20260908-1714-audio-019.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-019.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS011.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 15741, + "sha256": "2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb", + "objectKey": "tang-detective/season-01/media-v1/2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb.mp3", + "stagedName": "tang-20260908-1714-audio-020.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-020.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS012.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 32697, + "sha256": "33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a", + "objectKey": "tang-detective/season-01/media-v1/33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a.mp3", + "stagedName": "tang-20260908-1714-audio-021.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-021.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MT000.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4005, + "sha256": "5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e", + "objectKey": "tang-detective/season-01/media-v1/5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e.mp3", + "stagedName": "tang-20260908-1714-audio-022.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-022.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-TE900.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 7713, + "sha256": "d57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1", + "objectKey": "tang-detective/season-01/media-v1/d57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1.mp3", + "stagedName": "tang-20260908-1714-audio-023.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-023.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 103969, + "sha256": "c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552", + "objectKey": "tang-detective/season-01/media-v1/c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552.jpg", + "stagedName": "tang-20260908-1714-image-024.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-024.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128391, + "sha256": "892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea", + "objectKey": "tang-detective/season-01/media-v1/892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea.jpg", + "stagedName": "tang-20260908-1714-image-025.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-025.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 110191, + "sha256": "e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c", + "objectKey": "tang-detective/season-01/media-v1/e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c.jpg", + "stagedName": "tang-20260908-1714-image-026.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-026.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 125153, + "sha256": "ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593", + "objectKey": "tang-detective/season-01/media-v1/ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593.jpg", + "stagedName": "tang-20260908-1714-image-027.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-027.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128039, + "sha256": "cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d", + "objectKey": "tang-detective/season-01/media-v1/cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d.jpg", + "stagedName": "tang-20260908-1714-image-028.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-028.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 99846, + "sha256": "61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541", + "objectKey": "tang-detective/season-01/media-v1/61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541.jpg", + "stagedName": "tang-20260908-1714-image-029.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-029.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 133519, + "sha256": "c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539", + "objectKey": "tang-detective/season-01/media-v1/c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539.jpg", + "stagedName": "tang-20260908-1714-image-030.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-030.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128463, + "sha256": "6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703", + "objectKey": "tang-detective/season-01/media-v1/6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703.jpg", + "stagedName": "tang-20260908-1714-image-031.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-031.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS001.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 25821, + "sha256": "b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43", + "objectKey": "tang-detective/season-01/media-v1/b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43.mp3", + "stagedName": "tang-20260908-1714-audio-024.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-024.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS002-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 10557, + "sha256": "ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd", + "objectKey": "tang-detective/season-01/media-v1/ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd.mp3", + "stagedName": "tang-20260908-1714-audio-025.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-025.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS002.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6489, + "sha256": "c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c", + "objectKey": "tang-detective/season-01/media-v1/c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c.mp3", + "stagedName": "tang-20260908-1714-audio-026.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-026.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS003.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 20205, + "sha256": "af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184", + "objectKey": "tang-detective/season-01/media-v1/af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184.mp3", + "stagedName": "tang-20260908-1714-audio-027.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-027.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS004.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 7749, + "sha256": "c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da", + "objectKey": "tang-detective/season-01/media-v1/c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da.mp3", + "stagedName": "tang-20260908-1714-audio-028.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-028.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS005-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 5517, + "sha256": "4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e", + "objectKey": "tang-detective/season-01/media-v1/4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e.mp3", + "stagedName": "tang-20260908-1714-audio-029.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-029.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS005.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 10053, + "sha256": "46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442", + "objectKey": "tang-detective/season-01/media-v1/46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442.mp3", + "stagedName": "tang-20260908-1714-audio-030.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-030.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS006.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 13401, + "sha256": "2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5", + "objectKey": "tang-detective/season-01/media-v1/2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5.mp3", + "stagedName": "tang-20260908-1714-audio-031.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-031.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS007-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6309, + "sha256": "f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0", + "objectKey": "tang-detective/season-01/media-v1/f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0.mp3", + "stagedName": "tang-20260908-1714-audio-032.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-032.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS007.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6849, + "sha256": "68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038", + "objectKey": "tang-detective/season-01/media-v1/68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038.mp3", + "stagedName": "tang-20260908-1714-audio-033.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-033.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS008.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 5373, + "sha256": "d9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7", + "objectKey": "tang-detective/season-01/media-v1/d9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7.mp3", + "stagedName": "tang-20260908-1714-audio-034.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-034.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS009.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 20925, + "sha256": "97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed", + "objectKey": "tang-detective/season-01/media-v1/97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed.mp3", + "stagedName": "tang-20260908-1714-audio-035.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-035.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS010.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6165, + "sha256": "49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0", + "objectKey": "tang-detective/season-01/media-v1/49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0.mp3", + "stagedName": "tang-20260908-1714-audio-036.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-036.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS011.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 15777, + "sha256": "6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25", + "objectKey": "tang-detective/season-01/media-v1/6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25.mp3", + "stagedName": "tang-20260908-1714-audio-037.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-037.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS013.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 19017, + "sha256": "8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a", + "objectKey": "tang-detective/season-01/media-v1/8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a.mp3", + "stagedName": "tang-20260908-1714-audio-038.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-038.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS014.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4869, + "sha256": "8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0", + "objectKey": "tang-detective/season-01/media-v1/8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0.mp3", + "stagedName": "tang-20260908-1714-audio-039.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-039.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS015.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 14193, + "sha256": "661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb", + "objectKey": "tang-detective/season-01/media-v1/661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb.mp3", + "stagedName": "tang-20260908-1714-audio-040.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-040.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MT000.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4077, + "sha256": "374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65", + "objectKey": "tang-detective/season-01/media-v1/374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65.mp3", + "stagedName": "tang-20260908-1714-audio-041.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-041.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-TE900.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6813, + "sha256": "f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993", + "objectKey": "tang-detective/season-01/media-v1/f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993.mp3", + "stagedName": "tang-20260908-1714-audio-042.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-042.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 118129, + "sha256": "7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4", + "objectKey": "tang-detective/season-01/media-v1/7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4.jpg", + "stagedName": "tang-20260908-1714-image-032.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-032.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 194945, + "sha256": "feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5", + "objectKey": "tang-detective/season-01/media-v1/feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5.jpg", + "stagedName": "tang-20260908-1714-image-033.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-033.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 207111, + "sha256": "841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e", + "objectKey": "tang-detective/season-01/media-v1/841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e.jpg", + "stagedName": "tang-20260908-1714-image-034.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-034.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 199826, + "sha256": "8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727", + "objectKey": "tang-detective/season-01/media-v1/8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727.jpg", + "stagedName": "tang-20260908-1714-image-035.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-035.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 171496, + "sha256": "cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79", + "objectKey": "tang-detective/season-01/media-v1/cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79.jpg", + "stagedName": "tang-20260908-1714-image-036.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-036.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 84540, + "sha256": "00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1", + "objectKey": "tang-detective/season-01/media-v1/00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1.jpg", + "stagedName": "tang-20260908-1714-image-037.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-037.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 182868, + "sha256": "787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602", + "objectKey": "tang-detective/season-01/media-v1/787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602.jpg", + "stagedName": "tang-20260908-1714-image-038.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-038.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 210097, + "sha256": "056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5", + "objectKey": "tang-detective/season-01/media-v1/056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5.jpg", + "stagedName": "tang-20260908-1714-image-039.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-039.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 83596, + "sha256": "e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30", + "objectKey": "tang-detective/season-01/media-v1/e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30.jpg", + "stagedName": "tang-20260908-1714-image-040.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-040.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 105099, + "sha256": "d83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505", + "objectKey": "tang-detective/season-01/media-v1/d83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505.jpg", + "stagedName": "tang-20260908-1714-image-041.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-041.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 91939, + "sha256": "c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4", + "objectKey": "tang-detective/season-01/media-v1/c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4.jpg", + "stagedName": "tang-20260908-1714-image-042.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-042.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 89468, + "sha256": "70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7", + "objectKey": "tang-detective/season-01/media-v1/70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7.jpg", + "stagedName": "tang-20260908-1714-image-043.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-043.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 93259, + "sha256": "4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a", + "objectKey": "tang-detective/season-01/media-v1/4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a.jpg", + "stagedName": "tang-20260908-1714-image-044.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-044.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 92724, + "sha256": "25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2", + "objectKey": "tang-detective/season-01/media-v1/25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2.jpg", + "stagedName": "tang-20260908-1714-image-045.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-045.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 98885, + "sha256": "3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d", + "objectKey": "tang-detective/season-01/media-v1/3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d.jpg", + "stagedName": "tang-20260908-1714-image-046.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-046.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 104015, + "sha256": "9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61", + "objectKey": "tang-detective/season-01/media-v1/9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61.jpg", + "stagedName": "tang-20260908-1714-image-047.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-047.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 113102, + "sha256": "52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef", + "objectKey": "tang-detective/season-01/media-v1/52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef.jpg", + "stagedName": "tang-20260908-1714-image-048.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-048.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 156680, + "sha256": "897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb", + "objectKey": "tang-detective/season-01/media-v1/897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb.jpg", + "stagedName": "tang-20260908-1714-image-049.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-049.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 147490, + "sha256": "76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22", + "objectKey": "tang-detective/season-01/media-v1/76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22.jpg", + "stagedName": "tang-20260908-1714-image-050.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-050.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 124380, + "sha256": "c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c", + "objectKey": "tang-detective/season-01/media-v1/c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c.jpg", + "stagedName": "tang-20260908-1714-image-051.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-051.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 159817, + "sha256": "a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8", + "objectKey": "tang-detective/season-01/media-v1/a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8.jpg", + "stagedName": "tang-20260908-1714-image-052.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-052.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 146026, + "sha256": "63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca", + "objectKey": "tang-detective/season-01/media-v1/63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca.jpg", + "stagedName": "tang-20260908-1714-image-053.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-053.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 150951, + "sha256": "172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567", + "objectKey": "tang-detective/season-01/media-v1/172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567.jpg", + "stagedName": "tang-20260908-1714-image-054.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-054.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190776, + "sha256": "b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f", + "objectKey": "tang-detective/season-01/media-v1/b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f.jpg", + "stagedName": "tang-20260908-1714-image-055.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-055.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190405, + "sha256": "f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40", + "objectKey": "tang-detective/season-01/media-v1/f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40.jpg", + "stagedName": "tang-20260908-1714-image-056.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-056.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 140303, + "sha256": "0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae", + "objectKey": "tang-detective/season-01/media-v1/0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae.jpg", + "stagedName": "tang-20260908-1714-image-057.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-057.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 181320, + "sha256": "caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75", + "objectKey": "tang-detective/season-01/media-v1/caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75.jpg", + "stagedName": "tang-20260908-1714-image-058.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-058.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 150118, + "sha256": "f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469", + "objectKey": "tang-detective/season-01/media-v1/f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469.jpg", + "stagedName": "tang-20260908-1714-image-059.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-059.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 154740, + "sha256": "408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d", + "objectKey": "tang-detective/season-01/media-v1/408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d.jpg", + "stagedName": "tang-20260908-1714-image-060.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-060.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 142954, + "sha256": "84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53", + "objectKey": "tang-detective/season-01/media-v1/84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53.jpg", + "stagedName": "tang-20260908-1714-image-061.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-061.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 152752, + "sha256": "c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b", + "objectKey": "tang-detective/season-01/media-v1/c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b.jpg", + "stagedName": "tang-20260908-1714-image-062.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-062.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 170210, + "sha256": "bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5", + "objectKey": "tang-detective/season-01/media-v1/bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5.jpg", + "stagedName": "tang-20260908-1714-image-063.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-063.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 165311, + "sha256": "42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720", + "objectKey": "tang-detective/season-01/media-v1/42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720.jpg", + "stagedName": "tang-20260908-1714-image-064.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-064.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 173413, + "sha256": "81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2", + "objectKey": "tang-detective/season-01/media-v1/81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2.jpg", + "stagedName": "tang-20260908-1714-image-065.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-065.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128377, + "sha256": "fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5", + "objectKey": "tang-detective/season-01/media-v1/fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5.jpg", + "stagedName": "tang-20260908-1714-image-066.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-066.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 147055, + "sha256": "8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3", + "objectKey": "tang-detective/season-01/media-v1/8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3.jpg", + "stagedName": "tang-20260908-1714-image-067.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-067.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157850, + "sha256": "5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4", + "objectKey": "tang-detective/season-01/media-v1/5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4.jpg", + "stagedName": "tang-20260908-1714-image-068.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-068.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 174837, + "sha256": "7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a", + "objectKey": "tang-detective/season-01/media-v1/7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a.jpg", + "stagedName": "tang-20260908-1714-image-069.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-069.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 130849, + "sha256": "19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5", + "objectKey": "tang-detective/season-01/media-v1/19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5.jpg", + "stagedName": "tang-20260908-1714-image-070.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-070.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 161095, + "sha256": "f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e", + "objectKey": "tang-detective/season-01/media-v1/f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e.jpg", + "stagedName": "tang-20260908-1714-image-071.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-071.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157367, + "sha256": "b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc", + "objectKey": "tang-detective/season-01/media-v1/b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc.jpg", + "stagedName": "tang-20260908-1714-image-072.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-072.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 191697, + "sha256": "4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562", + "objectKey": "tang-detective/season-01/media-v1/4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562.jpg", + "stagedName": "tang-20260908-1714-image-073.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-073.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 198741, + "sha256": "2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c", + "objectKey": "tang-detective/season-01/media-v1/2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c.jpg", + "stagedName": "tang-20260908-1714-image-074.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-074.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 178634, + "sha256": "737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935", + "objectKey": "tang-detective/season-01/media-v1/737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935.jpg", + "stagedName": "tang-20260908-1714-image-075.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-075.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 180813, + "sha256": "41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618", + "objectKey": "tang-detective/season-01/media-v1/41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618.jpg", + "stagedName": "tang-20260908-1714-image-076.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-076.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 163905, + "sha256": "fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111", + "objectKey": "tang-detective/season-01/media-v1/fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111.jpg", + "stagedName": "tang-20260908-1714-image-077.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-077.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 116785, + "sha256": "6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea", + "objectKey": "tang-detective/season-01/media-v1/6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea.jpg", + "stagedName": "tang-20260908-1714-image-078.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-078.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 138725, + "sha256": "2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743", + "objectKey": "tang-detective/season-01/media-v1/2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743.jpg", + "stagedName": "tang-20260908-1714-image-079.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-079.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 150476, + "sha256": "59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f", + "objectKey": "tang-detective/season-01/media-v1/59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f.jpg", + "stagedName": "tang-20260908-1714-image-080.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-080.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 179019, + "sha256": "0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d", + "objectKey": "tang-detective/season-01/media-v1/0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d.jpg", + "stagedName": "tang-20260908-1714-image-081.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-081.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 192720, + "sha256": "9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af", + "objectKey": "tang-detective/season-01/media-v1/9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af.jpg", + "stagedName": "tang-20260908-1714-image-082.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-082.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 139199, + "sha256": "cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff", + "objectKey": "tang-detective/season-01/media-v1/cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff.jpg", + "stagedName": "tang-20260908-1714-image-083.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-083.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 164727, + "sha256": "5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a", + "objectKey": "tang-detective/season-01/media-v1/5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a.jpg", + "stagedName": "tang-20260908-1714-image-084.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-084.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 180695, + "sha256": "5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443", + "objectKey": "tang-detective/season-01/media-v1/5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443.jpg", + "stagedName": "tang-20260908-1714-image-085.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-085.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 176826, + "sha256": "9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f", + "objectKey": "tang-detective/season-01/media-v1/9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f.jpg", + "stagedName": "tang-20260908-1714-image-086.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-086.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 152317, + "sha256": "b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724", + "objectKey": "tang-detective/season-01/media-v1/b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724.jpg", + "stagedName": "tang-20260908-1714-image-087.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-087.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 170147, + "sha256": "42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8", + "objectKey": "tang-detective/season-01/media-v1/42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8.jpg", + "stagedName": "tang-20260908-1714-image-088.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-088.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 201027, + "sha256": "30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55", + "objectKey": "tang-detective/season-01/media-v1/30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55.jpg", + "stagedName": "tang-20260908-1714-image-089.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-089.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 145630, + "sha256": "23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9", + "objectKey": "tang-detective/season-01/media-v1/23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9.jpg", + "stagedName": "tang-20260908-1714-image-090.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-090.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 142107, + "sha256": "be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde", + "objectKey": "tang-detective/season-01/media-v1/be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde.jpg", + "stagedName": "tang-20260908-1714-image-091.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-091.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 189031, + "sha256": "a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd", + "objectKey": "tang-detective/season-01/media-v1/a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd.jpg", + "stagedName": "tang-20260908-1714-image-092.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-092.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 161904, + "sha256": "6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446", + "objectKey": "tang-detective/season-01/media-v1/6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446.jpg", + "stagedName": "tang-20260908-1714-image-093.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-093.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157096, + "sha256": "13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99", + "objectKey": "tang-detective/season-01/media-v1/13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99.jpg", + "stagedName": "tang-20260908-1714-image-094.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-094.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 205032, + "sha256": "2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0", + "objectKey": "tang-detective/season-01/media-v1/2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0.jpg", + "stagedName": "tang-20260908-1714-image-095.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-095.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 170560, + "sha256": "840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a", + "objectKey": "tang-detective/season-01/media-v1/840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a.jpg", + "stagedName": "tang-20260908-1714-image-096.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-096.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 122506, + "sha256": "112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1", + "objectKey": "tang-detective/season-01/media-v1/112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1.jpg", + "stagedName": "tang-20260908-1714-image-097.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-097.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 95514, + "sha256": "23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51", + "objectKey": "tang-detective/season-01/media-v1/23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51.jpg", + "stagedName": "tang-20260908-1714-image-098.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-098.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 185485, + "sha256": "3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50", + "objectKey": "tang-detective/season-01/media-v1/3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50.jpg", + "stagedName": "tang-20260908-1714-image-099.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-099.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 120040, + "sha256": "c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5", + "objectKey": "tang-detective/season-01/media-v1/c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5.jpg", + "stagedName": "tang-20260908-1714-image-100.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-100.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 172410, + "sha256": "4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c", + "objectKey": "tang-detective/season-01/media-v1/4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c.jpg", + "stagedName": "tang-20260908-1714-image-101.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-101.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157302, + "sha256": "9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a", + "objectKey": "tang-detective/season-01/media-v1/9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a.jpg", + "stagedName": "tang-20260908-1714-image-102.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-102.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 112557, + "sha256": "b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de", + "objectKey": "tang-detective/season-01/media-v1/b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de.jpg", + "stagedName": "tang-20260908-1714-image-103.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-103.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 167948, + "sha256": "e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a", + "objectKey": "tang-detective/season-01/media-v1/e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a.jpg", + "stagedName": "tang-20260908-1714-image-104.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-104.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190911, + "sha256": "6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602", + "objectKey": "tang-detective/season-01/media-v1/6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602.jpg", + "stagedName": "tang-20260908-1714-image-105.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-105.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 133362, + "sha256": "c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9", + "objectKey": "tang-detective/season-01/media-v1/c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9.jpg", + "stagedName": "tang-20260908-1714-image-106.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-106.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 152941, + "sha256": "1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e", + "objectKey": "tang-detective/season-01/media-v1/1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e.jpg", + "stagedName": "tang-20260908-1714-image-107.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-107.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 164550, + "sha256": "695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0", + "objectKey": "tang-detective/season-01/media-v1/695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0.jpg", + "stagedName": "tang-20260908-1714-image-108.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-108.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 153998, + "sha256": "9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76", + "objectKey": "tang-detective/season-01/media-v1/9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76.jpg", + "stagedName": "tang-20260908-1714-image-109.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-109.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 195056, + "sha256": "338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5", + "objectKey": "tang-detective/season-01/media-v1/338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5.jpg", + "stagedName": "tang-20260908-1714-image-110.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-110.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 135599, + "sha256": "a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f", + "objectKey": "tang-detective/season-01/media-v1/a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f.jpg", + "stagedName": "tang-20260908-1714-image-111.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-111.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 191222, + "sha256": "2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e", + "objectKey": "tang-detective/season-01/media-v1/2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e.jpg", + "stagedName": "tang-20260908-1714-image-112.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-112.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 201951, + "sha256": "a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198", + "objectKey": "tang-detective/season-01/media-v1/a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198.jpg", + "stagedName": "tang-20260908-1714-image-113.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-113.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 201320, + "sha256": "13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b", + "objectKey": "tang-detective/season-01/media-v1/13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b.jpg", + "stagedName": "tang-20260908-1714-image-114.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-114.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 173154, + "sha256": "d4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d", + "objectKey": "tang-detective/season-01/media-v1/d4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d.jpg", + "stagedName": "tang-20260908-1714-image-115.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-115.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 214867, + "sha256": "2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff", + "objectKey": "tang-detective/season-01/media-v1/2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff.jpg", + "stagedName": "tang-20260908-1714-image-116.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-116.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 184565, + "sha256": "8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d", + "objectKey": "tang-detective/season-01/media-v1/8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d.jpg", + "stagedName": "tang-20260908-1714-image-117.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-117.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 198924, + "sha256": "edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b", + "objectKey": "tang-detective/season-01/media-v1/edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b.jpg", + "stagedName": "tang-20260908-1714-image-118.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-118.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 179621, + "sha256": "f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc", + "objectKey": "tang-detective/season-01/media-v1/f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc.jpg", + "stagedName": "tang-20260908-1714-image-119.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-119.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 173212, + "sha256": "6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2", + "objectKey": "tang-detective/season-01/media-v1/6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2.jpg", + "stagedName": "tang-20260908-1714-image-120.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-120.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 204940, + "sha256": "15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48", + "objectKey": "tang-detective/season-01/media-v1/15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48.jpg", + "stagedName": "tang-20260908-1714-image-121.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-121.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 181140, + "sha256": "9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365", + "objectKey": "tang-detective/season-01/media-v1/9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365.jpg", + "stagedName": "tang-20260908-1714-image-122.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-122.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190877, + "sha256": "899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e", + "objectKey": "tang-detective/season-01/media-v1/899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e.jpg", + "stagedName": "tang-20260908-1714-image-123.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-123.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 181882, + "sha256": "b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801", + "objectKey": "tang-detective/season-01/media-v1/b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801.jpg", + "stagedName": "tang-20260908-1714-image-124.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-124.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 192247, + "sha256": "c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd", + "objectKey": "tang-detective/season-01/media-v1/c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd.jpg", + "stagedName": "tang-20260908-1714-image-125.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-125.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 166223, + "sha256": "d0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d", + "objectKey": "tang-detective/season-01/media-v1/d0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d.jpg", + "stagedName": "tang-20260908-1714-image-126.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-126.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 172139, + "sha256": "09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c", + "objectKey": "tang-detective/season-01/media-v1/09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c.jpg", + "stagedName": "tang-20260908-1714-image-127.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-127.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 207370, + "sha256": "194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab", + "objectKey": "tang-detective/season-01/media-v1/194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab.jpg", + "stagedName": "tang-20260908-1714-image-128.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-128.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 189454, + "sha256": "b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b", + "objectKey": "tang-detective/season-01/media-v1/b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b.jpg", + "stagedName": "tang-20260908-1714-image-129.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-129.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 162103, + "sha256": "b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173", + "objectKey": "tang-detective/season-01/media-v1/b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173.jpg", + "stagedName": "tang-20260908-1714-image-130.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-130.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 138672, + "sha256": "2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea", + "objectKey": "tang-detective/season-01/media-v1/2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea.jpg", + "stagedName": "tang-20260908-1714-image-131.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-131.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 166021, + "sha256": "21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a", + "objectKey": "tang-detective/season-01/media-v1/21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a.jpg", + "stagedName": "tang-20260908-1714-image-132.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-132.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 172654, + "sha256": "3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50", + "objectKey": "tang-detective/season-01/media-v1/3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50.jpg", + "stagedName": "tang-20260908-1714-image-133.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-133.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 210429, + "sha256": "98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98", + "objectKey": "tang-detective/season-01/media-v1/98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98.jpg", + "stagedName": "tang-20260908-1714-image-134.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-134.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 96215, + "sha256": "520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113", + "objectKey": "tang-detective/season-01/media-v1/520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113.jpg", + "stagedName": "tang-20260908-1714-image-135.jpg", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-image-135.jpg", + "observedUrl": null + }, + { + "sourcePath": "package-game/assets/audio/S01-C01-MS007.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 18957, + "sha256": "1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8", + "objectKey": "tang-detective/season-01/media-v1/1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8.mp3", + "stagedName": "tang-20260908-1714-audio-043.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-043.mp3", + "observedUrl": null + }, + { + "sourcePath": "package-game/assets/audio/S01-C01-MS010.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 17421, + "sha256": "628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597", + "objectKey": "tang-detective/season-01/media-v1/628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597.mp3", + "stagedName": "tang-20260908-1714-audio-044.mp3", + "stagedPath": "/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/upload-staging/tang-20260908-1714-audio-044.mp3", + "observedUrl": null + } + ] +} diff --git a/TongjiUniApp/build/tang-detective-admin-upload-receipt.json b/TongjiUniApp/build/tang-detective-admin-upload-receipt.json new file mode 100644 index 0000000..2e7f978 --- /dev/null +++ b/TongjiUniApp/build/tang-detective-admin-upload-receipt.json @@ -0,0 +1,2541 @@ +{ + "schemaVersion": 1, + "runId": "b9ab703a-49a6-4b6b-ac34-979eca1d4828", + "mode": "activate", + "startedAt": "2026-09-08T09:36:03.966Z", + "phase": "complete", + "complete": true, + "uploadTransport": "authenticated-existing-admin-ui", + "destination": { + "bucket": "gz-1349751149", + "region": "ap-guangzhou", + "baseUrl": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com" + }, + "sourceManifestSha256": "3b203406a31fdbe17c770aeea0bab19755acfedf3fa1595641ee4e15a9712e20", + "bucketPermissionsChanged": false, + "originalFilesChanged": false, + "objects": [ + { + "sourcePath": "assets/characters/female-cook.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 11958, + "sha256": "c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f", + "objectKey": "uploads/images/20260908/20260908172953806b83817.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172953806b83817.jpg", + "remoteVerifiedSha256": "c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.290Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "assets/characters/qin-xiaoman.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 92914, + "sha256": "22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5", + "objectKey": "uploads/images/20260908/2026090817295416f2f2841.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817295416f2f2841.jpg", + "remoteVerifiedSha256": "22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.337Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "assets/characters/lele.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 102681, + "sha256": "807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168", + "objectKey": "uploads/images/20260908/20260908172954a3c700294.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954a3c700294.jpg", + "remoteVerifiedSha256": "807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.339Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "assets/characters/lin-xiulan.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 107355, + "sha256": "56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361", + "objectKey": "uploads/images/20260908/202609081729547cbb37718.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081729547cbb37718.jpg", + "remoteVerifiedSha256": "56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.370Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "assets/characters/tang-mingyuan.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 92880, + "sha256": "5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb", + "objectKey": "uploads/images/20260908/20260908172954fef263003.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954fef263003.jpg", + "remoteVerifiedSha256": "5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.537Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "assets/characters/qin-zhicheng.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 116799, + "sha256": "7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2", + "objectKey": "uploads/images/20260908/202609081729541a4a74577.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081729541a4a74577.jpg", + "remoteVerifiedSha256": "7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.629Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "assets/characters/tang-shouan.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 108663, + "sha256": "4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89", + "objectKey": "uploads/images/20260908/20260908172954a75561985.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954a75561985.jpg", + "remoteVerifiedSha256": "4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.631Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-audio-c01-a/assets/player-art/S01-C01-P02-missing-table-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 97655, + "sha256": "f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0", + "objectKey": "uploads/images/20260908/20260908172954af5606377.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954af5606377.jpg", + "remoteVerifiedSha256": "f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.749Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "assets/characters/zhao-jianguo.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 100165, + "sha256": "1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0", + "objectKey": "uploads/images/20260908/2026090817295420b991364.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817295420b991364.jpg", + "remoteVerifiedSha256": "1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.760Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "assets/characters/xiaozhen.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 89720, + "sha256": "d5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb", + "objectKey": "uploads/images/20260908/20260908172954578d88427.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954578d88427.jpg", + "remoteVerifiedSha256": "d5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.828Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-05/assets/scenes/gui-xiang-1978.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "objectKey": "uploads/images/20260908/2026090817303974c831720.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303974c831720.jpg", + "remoteVerifiedSha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.868Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-07/assets/scenes/gui-xiang-1995.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 67246, + "sha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f", + "objectKey": "uploads/images/20260908/20260908173040de54b5498.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173040de54b5498.jpg", + "remoteVerifiedSha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.897Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-08/assets/scenes/gui-xiang-1998.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 62727, + "sha256": "d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14", + "objectKey": "uploads/images/20260908/2026090817303983bc35301.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303983bc35301.jpg", + "remoteVerifiedSha256": "d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.958Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-09/assets/scenes/gui-xiang-2001.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 59408, + "sha256": "9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8", + "objectKey": "uploads/images/20260908/20260908173039ad5bc2437.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039ad5bc2437.jpg", + "remoteVerifiedSha256": "9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.969Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-10/assets/scenes/gui-xiang-2003.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 53822, + "sha256": "68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431", + "objectKey": "uploads/images/20260908/2026090817303985ee07761.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303985ee07761.jpg", + "remoteVerifiedSha256": "68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.986Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-11/assets/scenes/gui-xiang-2008.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 53286, + "sha256": "c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5", + "objectKey": "uploads/images/20260908/2026090817303994b4b1401.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303994b4b1401.jpg", + "remoteVerifiedSha256": "c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.021Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-game/assets/scenes/gui-xiang-2026.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "objectKey": "uploads/images/20260908/20260908173039628910741.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039628910741.jpg", + "remoteVerifiedSha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.155Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 99232, + "sha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69", + "objectKey": "uploads/images/20260908/202609081718480ee488780.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081718480ee488780.jpg", + "remoteVerifiedSha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.157Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P01.daa8f04a2627.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 270139, + "sha256": "daa8f04a262767109f3a0621f178e3c58f169507e8ea09fa8d583258cb713282", + "objectKey": "uploads/voice/20260908/1788859559652-e90yvvdk.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859559652-e90yvvdk.mp3", + "remoteVerifiedSha256": "daa8f04a262767109f3a0621f178e3c58f169507e8ea09fa8d583258cb713282", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.330Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P02.7774981b1878.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 396572, + "sha256": "7774981b1878f6ad882cb7a4e3f923d67f43c12c2ea068982df1cab63820d8c2", + "objectKey": "uploads/voice/20260908/1788859640935-23ixzy68.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859640935-23ixzy68.mp3", + "remoteVerifiedSha256": "7774981b1878f6ad882cb7a4e3f923d67f43c12c2ea068982df1cab63820d8c2", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.349Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P03.0f21daa7eccb.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 244853, + "sha256": "0f21daa7eccbaac9139317e8209570fe025fb2403ea5cc794e12586764365885", + "objectKey": "uploads/voice/20260908/1788859681917-n7wb94nh.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859681917-n7wb94nh.mp3", + "remoteVerifiedSha256": "0f21daa7eccbaac9139317e8209570fe025fb2403ea5cc794e12586764365885", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.433Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 91819, + "sha256": "6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80", + "objectKey": "uploads/images/20260908/202609081730407a8a27957.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081730407a8a27957.jpg", + "remoteVerifiedSha256": "6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.482Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P04.ed65c367d761.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 375047, + "sha256": "ed65c367d76125e1404d76bb86faa0629b599f6853a39e58821617f854bfa167", + "objectKey": "uploads/voice/20260908/1788859683117-o8y8c1ap.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859683117-o8y8c1ap.mp3", + "remoteVerifiedSha256": "ed65c367d76125e1404d76bb86faa0629b599f6853a39e58821617f854bfa167", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.521Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 120832, + "sha256": "bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce", + "objectKey": "uploads/images/20260908/202609081730409483f1003.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081730409483f1003.jpg", + "remoteVerifiedSha256": "bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.546Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 105278, + "sha256": "12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a", + "objectKey": "uploads/images/20260908/20260908173039668f11872.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039668f11872.jpg", + "remoteVerifiedSha256": "12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.577Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P07.a68bc0cb8ecc.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 84356, + "sha256": "a68bc0cb8ecc6d53b7c877567d6c21fdd894c914e84c072357116429d05d0cc5", + "objectKey": "uploads/voice/20260908/1788859686361-f4myg1jo.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859686361-f4myg1jo.mp3", + "remoteVerifiedSha256": "a68bc0cb8ecc6d53b7c877567d6c21fdd894c914e84c072357116429d05d0cc5", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.783Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P05.210005d24e32.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 263661, + "sha256": "210005d24e3276e2689e38b0bb3a9d6b6e900ca3cc5ca874278a0b89a50def6a", + "objectKey": "uploads/voice/20260908/1788859684272-vx02qyha.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859684272-vx02qyha.mp3", + "remoteVerifiedSha256": "210005d24e3276e2689e38b0bb3a9d6b6e900ca3cc5ca874278a0b89a50def6a", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.829Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P08.1c44eff9d71f.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 316951, + "sha256": "1c44eff9d71fcd004e53050e0727ee37fa3560509d3579c0e2cd004e80e246ec", + "objectKey": "uploads/voice/20260908/1788859687423-at9k5kro.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859687423-at9k5kro.mp3", + "remoteVerifiedSha256": "1c44eff9d71fcd004e53050e0727ee37fa3560509d3579c0e2cd004e80e246ec", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.832Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P06.5e69ed4c17f3.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 107344, + "sha256": "5e69ed4c17f3d5c4168546c0b76a6d5bfb13ecbecdd982c5be1c27259b9ce1a4", + "objectKey": "uploads/voice/20260908/1788859685309-bq8hczp2.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859685309-bq8hczp2.mp3", + "remoteVerifiedSha256": "5e69ed4c17f3d5c4168546c0b76a6d5bfb13ecbecdd982c5be1c27259b9ce1a4", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.874Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 111834, + "sha256": "d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f", + "objectKey": "uploads/images/20260908/2026090817310719bb62047.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817310719bb62047.jpg", + "remoteVerifiedSha256": "d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.911Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 96418, + "sha256": "4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31", + "objectKey": "uploads/images/20260908/20260908173107b3c447822.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173107b3c447822.jpg", + "remoteVerifiedSha256": "4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.964Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 120294, + "sha256": "7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750", + "objectKey": "uploads/images/20260908/202609081731073a6bf7270.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731073a6bf7270.jpg", + "remoteVerifiedSha256": "7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.975Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS003.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 19053, + "sha256": "3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f", + "objectKey": "uploads/voice/20260908/1788859706721-j2czunoh.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859706721-j2czunoh.mp3", + "remoteVerifiedSha256": "3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.203Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS002-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4797, + "sha256": "aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8", + "objectKey": "uploads/voice/20260908/1788859689813-2u5eyx28.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859689813-2u5eyx28.mp3", + "remoteVerifiedSha256": "aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.241Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS002.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6417, + "sha256": "ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe", + "objectKey": "uploads/voice/20260908/1788859705645-v0njwhr6.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859705645-v0njwhr6.mp3", + "remoteVerifiedSha256": "ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.282Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS001.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 19125, + "sha256": "580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233", + "objectKey": "uploads/voice/20260908/1788859688761-rv0vx59b.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859688761-rv0vx59b.mp3", + "remoteVerifiedSha256": "580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.288Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS004.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 8505, + "sha256": "e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c", + "objectKey": "uploads/voice/20260908/1788859707781-5eif2sva.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859707781-5eif2sva.mp3", + "remoteVerifiedSha256": "e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.428Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS005.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 7749, + "sha256": "6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e", + "objectKey": "uploads/voice/20260908/1788859708857-lgkflk7r.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859708857-lgkflk7r.mp3", + "remoteVerifiedSha256": "6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.475Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS007.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 12213, + "sha256": "7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c", + "objectKey": "uploads/voice/20260908/1788859710994-9ja52nj4.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859710994-9ja52nj4.mp3", + "remoteVerifiedSha256": "7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.508Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS006.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 12465, + "sha256": "067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88", + "objectKey": "uploads/voice/20260908/1788859709963-w40dseme.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859709963-w40dseme.mp3", + "remoteVerifiedSha256": "067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.521Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS008.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 12465, + "sha256": "288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f", + "objectKey": "uploads/voice/20260908/1788859712054-pqgtblz7.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859712054-pqgtblz7.mp3", + "remoteVerifiedSha256": "288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.729Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS009.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6993, + "sha256": "5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2", + "objectKey": "uploads/voice/20260908/1788859713116-rtg8ou4j.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859713116-rtg8ou4j.mp3", + "remoteVerifiedSha256": "5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.731Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS010.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 27189, + "sha256": "d371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d", + "objectKey": "uploads/voice/20260908/1788859714260-41ehj12t.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859714260-41ehj12t.mp3", + "remoteVerifiedSha256": "d371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.738Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS011.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 15741, + "sha256": "2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb", + "objectKey": "uploads/voice/20260908/1788859715307-zvzm6c52.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859715307-zvzm6c52.mp3", + "remoteVerifiedSha256": "2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.824Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 103969, + "sha256": "c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552", + "objectKey": "uploads/images/20260908/202609081731074bb984948.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731074bb984948.jpg", + "remoteVerifiedSha256": "c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:06.946Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS012.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 32697, + "sha256": "33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a", + "objectKey": "uploads/voice/20260908/1788859716357-ddolkq84.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859716357-ddolkq84.mp3", + "remoteVerifiedSha256": "33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.949Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-TE900.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 7713, + "sha256": "d57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1", + "objectKey": "uploads/voice/20260908/1788859718453-7taqslda.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859718453-7taqslda.mp3", + "remoteVerifiedSha256": "d57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.963Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128391, + "sha256": "892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea", + "objectKey": "uploads/images/20260908/202609081731073a9ad6102.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731073a9ad6102.jpg", + "remoteVerifiedSha256": "892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:07.088Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 125153, + "sha256": "ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593", + "objectKey": "uploads/images/20260908/202609081731075b3e30712.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731075b3e30712.jpg", + "remoteVerifiedSha256": "ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:07.096Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MT000.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4005, + "sha256": "5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e", + "objectKey": "uploads/voice/20260908/1788859717407-joxtjydj.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859717407-joxtjydj.mp3", + "remoteVerifiedSha256": "5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.097Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 110191, + "sha256": "e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c", + "objectKey": "uploads/images/20260908/202609081731076ef7d1167.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731076ef7d1167.jpg", + "remoteVerifiedSha256": "e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:07.101Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128463, + "sha256": "6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703", + "objectKey": "uploads/images/20260908/20260908173112ff8887242.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112ff8887242.jpg", + "remoteVerifiedSha256": "6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:07.254Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 99846, + "sha256": "61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541", + "objectKey": "uploads/images/20260908/202609081731078ac2d7004.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731078ac2d7004.jpg", + "remoteVerifiedSha256": "61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:07.258Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 133519, + "sha256": "c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539", + "objectKey": "uploads/images/20260908/20260908173107466700017.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173107466700017.jpg", + "remoteVerifiedSha256": "c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:07.303Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS001.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 25821, + "sha256": "b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43", + "objectKey": "uploads/voice/20260908/1788859719556-4h5g177p.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859719556-4h5g177p.mp3", + "remoteVerifiedSha256": "b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.480Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS002-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 10557, + "sha256": "ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd", + "objectKey": "uploads/voice/20260908/1788859720601-tm2rratq.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859720601-tm2rratq.mp3", + "remoteVerifiedSha256": "ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.497Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS002.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6489, + "sha256": "c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c", + "objectKey": "uploads/voice/20260908/1788859735790-h24dj5jw.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859735790-h24dj5jw.mp3", + "remoteVerifiedSha256": "c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.530Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS004.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 7749, + "sha256": "c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da", + "objectKey": "uploads/voice/20260908/1788859738725-lzs4obsj.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859738725-lzs4obsj.mp3", + "remoteVerifiedSha256": "c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.775Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS003.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 20205, + "sha256": "af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184", + "objectKey": "uploads/voice/20260908/1788859736846-tu8raupk.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859736846-tu8raupk.mp3", + "remoteVerifiedSha256": "af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.780Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS005-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 5517, + "sha256": "4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e", + "objectKey": "uploads/voice/20260908/1788859739954-0sdopsv6.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859739954-0sdopsv6.mp3", + "remoteVerifiedSha256": "4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.785Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128039, + "sha256": "cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d", + "objectKey": "uploads/images/20260908/202609081731072dfe55233.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731072dfe55233.jpg", + "remoteVerifiedSha256": "cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:07.787Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS005.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 10053, + "sha256": "46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442", + "objectKey": "uploads/voice/20260908/1788859741040-tnxabkmf.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859741040-tnxabkmf.mp3", + "remoteVerifiedSha256": "46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.981Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS007.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6849, + "sha256": "68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038", + "objectKey": "uploads/voice/20260908/1788859744174-pg41290p.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859744174-pg41290p.mp3", + "remoteVerifiedSha256": "68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.003Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS006.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 13401, + "sha256": "2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5", + "objectKey": "uploads/voice/20260908/1788859742080-q1gq96gr.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859742080-q1gq96gr.mp3", + "remoteVerifiedSha256": "2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.014Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS007-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6309, + "sha256": "f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0", + "objectKey": "uploads/voice/20260908/1788859743113-kgit8i2r.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859743113-kgit8i2r.mp3", + "remoteVerifiedSha256": "f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.020Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS009.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 20925, + "sha256": "97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed", + "objectKey": "uploads/voice/20260908/1788859746348-qrqomq07.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859746348-qrqomq07.mp3", + "remoteVerifiedSha256": "97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.395Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS010.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6165, + "sha256": "49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0", + "objectKey": "uploads/voice/20260908/1788859747502-csqqmkys.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859747502-csqqmkys.mp3", + "remoteVerifiedSha256": "49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.403Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS011.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 15777, + "sha256": "6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25", + "objectKey": "uploads/voice/20260908/1788859748680-hx5gjcjt.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859748680-hx5gjcjt.mp3", + "remoteVerifiedSha256": "6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.498Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS008.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 5373, + "sha256": "d9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7", + "objectKey": "uploads/voice/20260908/1788859745258-2u7m9wc0.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859745258-2u7m9wc0.mp3", + "remoteVerifiedSha256": "d9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.562Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS014.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4869, + "sha256": "8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0", + "objectKey": "uploads/voice/20260908/1788859750856-ubrs0bjv.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859750856-ubrs0bjv.mp3", + "remoteVerifiedSha256": "8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.613Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS013.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 19017, + "sha256": "8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a", + "objectKey": "uploads/voice/20260908/1788859749772-njzzx6f8.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859749772-njzzx6f8.mp3", + "remoteVerifiedSha256": "8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.625Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS015.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 14193, + "sha256": "661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb", + "objectKey": "uploads/voice/20260908/1788859751906-i84qzugn.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859751906-i84qzugn.mp3", + "remoteVerifiedSha256": "661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.699Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MT000.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4077, + "sha256": "374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65", + "objectKey": "uploads/voice/20260908/1788859752961-k632st94.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859752961-k632st94.mp3", + "remoteVerifiedSha256": "374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.821Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 118129, + "sha256": "7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4", + "objectKey": "uploads/images/20260908/20260908173112e49c90827.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112e49c90827.jpg", + "remoteVerifiedSha256": "7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:08.823Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 194945, + "sha256": "feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5", + "objectKey": "uploads/images/20260908/20260908173112365a59120.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112365a59120.jpg", + "remoteVerifiedSha256": "feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:08.847Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-TE900.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6813, + "sha256": "f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993", + "objectKey": "uploads/voice/20260908/1788859754110-nyj2b3le.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859754110-nyj2b3le.mp3", + "remoteVerifiedSha256": "f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.918Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 171496, + "sha256": "cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79", + "objectKey": "uploads/images/20260908/20260908173112353247130.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112353247130.jpg", + "remoteVerifiedSha256": "cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:08.971Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 207111, + "sha256": "841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e", + "objectKey": "uploads/images/20260908/20260908173112d30437983.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112d30437983.jpg", + "remoteVerifiedSha256": "841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:08.975Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 199826, + "sha256": "8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727", + "objectKey": "uploads/images/20260908/202609081731127140b3844.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731127140b3844.jpg", + "remoteVerifiedSha256": "8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:08.978Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 84540, + "sha256": "00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1", + "objectKey": "uploads/images/20260908/20260908173112169de0256.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112169de0256.jpg", + "remoteVerifiedSha256": "00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.038Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 210097, + "sha256": "056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5", + "objectKey": "uploads/images/20260908/202609081731122b3399354.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731122b3399354.jpg", + "remoteVerifiedSha256": "056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.119Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 182868, + "sha256": "787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602", + "objectKey": "uploads/images/20260908/20260908173112a505f4772.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112a505f4772.jpg", + "remoteVerifiedSha256": "787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.126Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 105099, + "sha256": "d83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505", + "objectKey": "uploads/images/20260908/2026090817311649e2a9550.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817311649e2a9550.jpg", + "remoteVerifiedSha256": "d83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.177Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 91939, + "sha256": "c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4", + "objectKey": "uploads/images/20260908/20260908173116162c66104.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116162c66104.jpg", + "remoteVerifiedSha256": "c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.246Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 83596, + "sha256": "e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30", + "objectKey": "uploads/images/20260908/20260908173112c4da43174.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112c4da43174.jpg", + "remoteVerifiedSha256": "e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.345Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 93259, + "sha256": "4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a", + "objectKey": "uploads/images/20260908/202609081731162a2021285.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731162a2021285.jpg", + "remoteVerifiedSha256": "4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.366Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 92724, + "sha256": "25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2", + "objectKey": "uploads/images/20260908/20260908173116813c87413.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116813c87413.jpg", + "remoteVerifiedSha256": "25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.381Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 89468, + "sha256": "70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7", + "objectKey": "uploads/images/20260908/20260908173116a53f24003.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116a53f24003.jpg", + "remoteVerifiedSha256": "70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.403Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 98885, + "sha256": "3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d", + "objectKey": "uploads/images/20260908/20260908173116bfec43397.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116bfec43397.jpg", + "remoteVerifiedSha256": "3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.490Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 113102, + "sha256": "52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef", + "objectKey": "uploads/images/20260908/20260908173116c5c618420.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116c5c618420.jpg", + "remoteVerifiedSha256": "52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.545Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 156680, + "sha256": "897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb", + "objectKey": "uploads/images/20260908/2026090817311649ffe3535.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817311649ffe3535.jpg", + "remoteVerifiedSha256": "897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.548Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 147490, + "sha256": "76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22", + "objectKey": "uploads/images/20260908/20260908173116ecae73791.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116ecae73791.jpg", + "remoteVerifiedSha256": "76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.638Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 124380, + "sha256": "c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c", + "objectKey": "uploads/images/20260908/202609081731352d7e82556.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731352d7e82556.jpg", + "remoteVerifiedSha256": "c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.678Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 159817, + "sha256": "a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8", + "objectKey": "uploads/images/20260908/2026090817313559ab83276.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313559ab83276.jpg", + "remoteVerifiedSha256": "a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.681Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 104015, + "sha256": "9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61", + "objectKey": "uploads/images/20260908/20260908173116dbdc29125.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116dbdc29125.jpg", + "remoteVerifiedSha256": "9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.696Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 150951, + "sha256": "172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567", + "objectKey": "uploads/images/20260908/202609081731350aa1d5788.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731350aa1d5788.jpg", + "remoteVerifiedSha256": "172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.947Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190405, + "sha256": "f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40", + "objectKey": "uploads/images/20260908/20260908173135b00ff3493.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173135b00ff3493.jpg", + "remoteVerifiedSha256": "f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.953Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190776, + "sha256": "b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f", + "objectKey": "uploads/images/20260908/202609081731357238e1677.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731357238e1677.jpg", + "remoteVerifiedSha256": "b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.955Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 150118, + "sha256": "f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469", + "objectKey": "uploads/images/20260908/2026090817313638f109229.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313638f109229.jpg", + "remoteVerifiedSha256": "f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.090Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 181320, + "sha256": "caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75", + "objectKey": "uploads/images/20260908/202609081731352d9fb1212.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731352d9fb1212.jpg", + "remoteVerifiedSha256": "caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.096Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 146026, + "sha256": "63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca", + "objectKey": "uploads/images/20260908/2026090817313533e949160.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313533e949160.jpg", + "remoteVerifiedSha256": "63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.303Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 142954, + "sha256": "84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53", + "objectKey": "uploads/images/20260908/20260908173138a61767853.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138a61767853.jpg", + "remoteVerifiedSha256": "84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.404Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 154740, + "sha256": "408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d", + "objectKey": "uploads/images/20260908/2026090817313660b804375.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313660b804375.jpg", + "remoteVerifiedSha256": "408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.407Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 140303, + "sha256": "0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae", + "objectKey": "uploads/images/20260908/20260908173135595af4252.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173135595af4252.jpg", + "remoteVerifiedSha256": "0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.422Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 165311, + "sha256": "42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720", + "objectKey": "uploads/images/20260908/20260908173138dff5a5536.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138dff5a5536.jpg", + "remoteVerifiedSha256": "42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.546Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 173413, + "sha256": "81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2", + "objectKey": "uploads/images/20260908/20260908173138191298369.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138191298369.jpg", + "remoteVerifiedSha256": "81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.555Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 152752, + "sha256": "c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b", + "objectKey": "uploads/images/20260908/20260908173138fa0762681.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138fa0762681.jpg", + "remoteVerifiedSha256": "c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.580Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 170210, + "sha256": "bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5", + "objectKey": "uploads/images/20260908/2026090817313839b279656.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313839b279656.jpg", + "remoteVerifiedSha256": "bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.603Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128377, + "sha256": "fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5", + "objectKey": "uploads/images/20260908/20260908173138ec8e94150.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138ec8e94150.jpg", + "remoteVerifiedSha256": "fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.699Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157850, + "sha256": "5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4", + "objectKey": "uploads/images/20260908/20260908173138c9d001251.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138c9d001251.jpg", + "remoteVerifiedSha256": "5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.732Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 174837, + "sha256": "7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a", + "objectKey": "uploads/images/20260908/20260908173139f46863978.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173139f46863978.jpg", + "remoteVerifiedSha256": "7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.746Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157367, + "sha256": "b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc", + "objectKey": "uploads/images/20260908/20260908173141cf0c01964.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173141cf0c01964.jpg", + "remoteVerifiedSha256": "b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.925Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 130849, + "sha256": "19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5", + "objectKey": "uploads/images/20260908/2026090817313967a307026.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313967a307026.jpg", + "remoteVerifiedSha256": "19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.996Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 147055, + "sha256": "8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3", + "objectKey": "uploads/images/20260908/20260908173138b2b761219.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138b2b761219.jpg", + "remoteVerifiedSha256": "8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.004Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 161095, + "sha256": "f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e", + "objectKey": "uploads/images/20260908/202609081731411da1c2125.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731411da1c2125.jpg", + "remoteVerifiedSha256": "f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.043Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 191697, + "sha256": "4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562", + "objectKey": "uploads/images/20260908/20260908173142053ed9914.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142053ed9914.jpg", + "remoteVerifiedSha256": "4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.082Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 198741, + "sha256": "2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c", + "objectKey": "uploads/images/20260908/20260908173142ef5784670.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142ef5784670.jpg", + "remoteVerifiedSha256": "2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.170Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 178634, + "sha256": "737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935", + "objectKey": "uploads/images/20260908/2026090817314274cfe2605.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817314274cfe2605.jpg", + "remoteVerifiedSha256": "737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.183Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 163905, + "sha256": "fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111", + "objectKey": "uploads/images/20260908/20260908173142d16d12935.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142d16d12935.jpg", + "remoteVerifiedSha256": "fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.237Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 138725, + "sha256": "2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743", + "objectKey": "uploads/images/20260908/202609081731429844c1305.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731429844c1305.jpg", + "remoteVerifiedSha256": "2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.308Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 116785, + "sha256": "6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea", + "objectKey": "uploads/images/20260908/202609081731425a0819698.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731425a0819698.jpg", + "remoteVerifiedSha256": "6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.312Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 192720, + "sha256": "9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af", + "objectKey": "uploads/images/20260908/202609081731451a0a48902.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731451a0a48902.jpg", + "remoteVerifiedSha256": "9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.454Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 179019, + "sha256": "0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d", + "objectKey": "uploads/images/20260908/20260908173145f82762147.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145f82762147.jpg", + "remoteVerifiedSha256": "0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.472Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 150476, + "sha256": "59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f", + "objectKey": "uploads/images/20260908/20260908173142346106887.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142346106887.jpg", + "remoteVerifiedSha256": "59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.490Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 180813, + "sha256": "41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618", + "objectKey": "uploads/images/20260908/202609081731420bc315230.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731420bc315230.jpg", + "remoteVerifiedSha256": "41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.494Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 139199, + "sha256": "cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff", + "objectKey": "uploads/images/20260908/20260908173145e43cc7774.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145e43cc7774.jpg", + "remoteVerifiedSha256": "cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.586Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 164727, + "sha256": "5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a", + "objectKey": "uploads/images/20260908/20260908173145050809349.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145050809349.jpg", + "remoteVerifiedSha256": "5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.615Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 180695, + "sha256": "5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443", + "objectKey": "uploads/images/20260908/202609081731456267e0864.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731456267e0864.jpg", + "remoteVerifiedSha256": "5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.636Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 176826, + "sha256": "9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f", + "objectKey": "uploads/images/20260908/202609081731451e62d8082.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731451e62d8082.jpg", + "remoteVerifiedSha256": "9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.742Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 170147, + "sha256": "42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8", + "objectKey": "uploads/images/20260908/20260908173145234414957.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145234414957.jpg", + "remoteVerifiedSha256": "42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.773Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 201027, + "sha256": "30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55", + "objectKey": "uploads/images/20260908/2026090817314577b3d2877.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817314577b3d2877.jpg", + "remoteVerifiedSha256": "30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.788Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 145630, + "sha256": "23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9", + "objectKey": "uploads/images/20260908/20260908173145d06c49932.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145d06c49932.jpg", + "remoteVerifiedSha256": "23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.972Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 189031, + "sha256": "a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd", + "objectKey": "uploads/images/20260908/202609081732034eb7f0368.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732034eb7f0368.jpg", + "remoteVerifiedSha256": "a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.039Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 142107, + "sha256": "be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde", + "objectKey": "uploads/images/20260908/202609081732037cfc44506.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732037cfc44506.jpg", + "remoteVerifiedSha256": "be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.088Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 161904, + "sha256": "6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446", + "objectKey": "uploads/images/20260908/202609081732039e6701414.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732039e6701414.jpg", + "remoteVerifiedSha256": "6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.136Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157096, + "sha256": "13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99", + "objectKey": "uploads/images/20260908/2026090817320373f998007.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320373f998007.jpg", + "remoteVerifiedSha256": "13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.202Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 205032, + "sha256": "2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0", + "objectKey": "uploads/images/20260908/20260908173203dace72023.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173203dace72023.jpg", + "remoteVerifiedSha256": "2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.295Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 152317, + "sha256": "b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724", + "objectKey": "uploads/images/20260908/2026090817314563ce19085.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817314563ce19085.jpg", + "remoteVerifiedSha256": "b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.338Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 122506, + "sha256": "112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1", + "objectKey": "uploads/images/20260908/20260908173203e27952820.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173203e27952820.jpg", + "remoteVerifiedSha256": "112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.402Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 95514, + "sha256": "23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51", + "objectKey": "uploads/images/20260908/202609081732032db544840.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732032db544840.jpg", + "remoteVerifiedSha256": "23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.495Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 170560, + "sha256": "840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a", + "objectKey": "uploads/images/20260908/202609081732035f2e17452.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732035f2e17452.jpg", + "remoteVerifiedSha256": "840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.497Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 185485, + "sha256": "3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50", + "objectKey": "uploads/images/20260908/202609081732035c9d61064.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732035c9d61064.jpg", + "remoteVerifiedSha256": "3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.499Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 172410, + "sha256": "4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c", + "objectKey": "uploads/images/20260908/2026090817320602c476049.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320602c476049.jpg", + "remoteVerifiedSha256": "4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.612Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157302, + "sha256": "9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a", + "objectKey": "uploads/images/20260908/20260908173206dae776115.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206dae776115.jpg", + "remoteVerifiedSha256": "9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.648Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 120040, + "sha256": "c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5", + "objectKey": "uploads/images/20260908/202609081732037c8583818.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732037c8583818.jpg", + "remoteVerifiedSha256": "c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.719Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 112557, + "sha256": "b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de", + "objectKey": "uploads/images/20260908/202609081732065676e2434.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732065676e2434.jpg", + "remoteVerifiedSha256": "b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.761Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190911, + "sha256": "6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602", + "objectKey": "uploads/images/20260908/20260908173206cb3721071.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206cb3721071.jpg", + "remoteVerifiedSha256": "6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.803Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 167948, + "sha256": "e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a", + "objectKey": "uploads/images/20260908/20260908173206a7ebe7583.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206a7ebe7583.jpg", + "remoteVerifiedSha256": "e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.828Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 133362, + "sha256": "c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9", + "objectKey": "uploads/images/20260908/20260908173206b42891414.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206b42891414.jpg", + "remoteVerifiedSha256": "c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.849Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 152941, + "sha256": "1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e", + "objectKey": "uploads/images/20260908/2026090817320615d6c3156.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320615d6c3156.jpg", + "remoteVerifiedSha256": "1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.899Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 153998, + "sha256": "9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76", + "objectKey": "uploads/images/20260908/202609081732067211a5043.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732067211a5043.jpg", + "remoteVerifiedSha256": "9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.018Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 135599, + "sha256": "a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f", + "objectKey": "uploads/images/20260908/20260908173210f33956967.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210f33956967.jpg", + "remoteVerifiedSha256": "a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.067Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 195056, + "sha256": "338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5", + "objectKey": "uploads/images/20260908/2026090817320628b5f1545.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320628b5f1545.jpg", + "remoteVerifiedSha256": "338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.076Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 164550, + "sha256": "695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0", + "objectKey": "uploads/images/20260908/20260908173206946d11690.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206946d11690.jpg", + "remoteVerifiedSha256": "695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.145Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 191222, + "sha256": "2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e", + "objectKey": "uploads/images/20260908/20260908173210dcc904301.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210dcc904301.jpg", + "remoteVerifiedSha256": "2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.154Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 201951, + "sha256": "a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198", + "objectKey": "uploads/images/20260908/202609081732104dda17591.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732104dda17591.jpg", + "remoteVerifiedSha256": "a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.213Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 201320, + "sha256": "13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b", + "objectKey": "uploads/images/20260908/202609081732103e1737129.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732103e1737129.jpg", + "remoteVerifiedSha256": "13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.231Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 173154, + "sha256": "d4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d", + "objectKey": "uploads/images/20260908/20260908173210052871552.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210052871552.jpg", + "remoteVerifiedSha256": "d4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.336Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 184565, + "sha256": "8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d", + "objectKey": "uploads/images/20260908/2026090817321063e243356.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321063e243356.jpg", + "remoteVerifiedSha256": "8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.352Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 198924, + "sha256": "edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b", + "objectKey": "uploads/images/20260908/202609081732103c91a8938.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732103c91a8938.jpg", + "remoteVerifiedSha256": "edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.382Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 214867, + "sha256": "2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff", + "objectKey": "uploads/images/20260908/202609081732106acce6348.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732106acce6348.jpg", + "remoteVerifiedSha256": "2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.431Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 204940, + "sha256": "15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48", + "objectKey": "uploads/images/20260908/2026090817321402ddc8471.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321402ddc8471.jpg", + "remoteVerifiedSha256": "15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.556Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 179621, + "sha256": "f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc", + "objectKey": "uploads/images/20260908/20260908173210e3e400579.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210e3e400579.jpg", + "remoteVerifiedSha256": "f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.604Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 173212, + "sha256": "6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2", + "objectKey": "uploads/images/20260908/20260908173210a1bfa8588.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210a1bfa8588.jpg", + "remoteVerifiedSha256": "6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.616Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 181140, + "sha256": "9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365", + "objectKey": "uploads/images/20260908/20260908173214a4a8d4074.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214a4a8d4074.jpg", + "remoteVerifiedSha256": "9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.669Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 181882, + "sha256": "b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801", + "objectKey": "uploads/images/20260908/202609081732146ee292192.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732146ee292192.jpg", + "remoteVerifiedSha256": "b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.778Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 192247, + "sha256": "c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd", + "objectKey": "uploads/images/20260908/2026090817321455aa99619.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321455aa99619.jpg", + "remoteVerifiedSha256": "c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.792Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190877, + "sha256": "899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e", + "objectKey": "uploads/images/20260908/202609081732148dba50220.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732148dba50220.jpg", + "remoteVerifiedSha256": "899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.808Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 166223, + "sha256": "d0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d", + "objectKey": "uploads/images/20260908/20260908173214765e24253.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214765e24253.jpg", + "remoteVerifiedSha256": "d0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.904Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 207370, + "sha256": "194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab", + "objectKey": "uploads/images/20260908/202609081732143d4070553.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732143d4070553.jpg", + "remoteVerifiedSha256": "194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.941Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 189454, + "sha256": "b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b", + "objectKey": "uploads/images/20260908/2026090817321405dc63371.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321405dc63371.jpg", + "remoteVerifiedSha256": "b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.969Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 172139, + "sha256": "09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c", + "objectKey": "uploads/images/20260908/20260908173214ac7b62068.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214ac7b62068.jpg", + "remoteVerifiedSha256": "09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:14.064Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 138672, + "sha256": "2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea", + "objectKey": "uploads/images/20260908/20260908173218201a61275.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173218201a61275.jpg", + "remoteVerifiedSha256": "2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:14.169Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 162103, + "sha256": "b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173", + "objectKey": "uploads/images/20260908/20260908173214896801232.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214896801232.jpg", + "remoteVerifiedSha256": "b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:14.197Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 172654, + "sha256": "3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50", + "objectKey": "uploads/images/20260908/202609081732189a41f9598.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732189a41f9598.jpg", + "remoteVerifiedSha256": "3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:14.274Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 166021, + "sha256": "21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a", + "objectKey": "uploads/images/20260908/20260908173218191279663.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173218191279663.jpg", + "remoteVerifiedSha256": "21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:14.279Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 96215, + "sha256": "520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113", + "objectKey": "uploads/images/20260908/20260908173218765170829.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173218765170829.jpg", + "remoteVerifiedSha256": "520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:14.435Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 210429, + "sha256": "98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98", + "objectKey": "uploads/images/20260908/202609081732189ef359918.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732189ef359918.jpg", + "remoteVerifiedSha256": "98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:14.438Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-game/assets/audio/S01-C01-MS007.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 18957, + "sha256": "1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8", + "objectKey": "uploads/voice/20260908/1788859755676-8bbjnyhb.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859755676-8bbjnyhb.mp3", + "remoteVerifiedSha256": "1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:14.653Z", + "uploaded": true, + "publicReadVerified": true + }, + { + "sourcePath": "package-game/assets/audio/S01-C01-MS010.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 17421, + "sha256": "628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597", + "objectKey": "uploads/voice/20260908/1788859756823-r37bn776.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859756823-r37bn776.mp3", + "remoteVerifiedSha256": "628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:14.671Z", + "uploaded": true, + "publicReadVerified": true + } + ], + "mediaManifestSha256": "0062a1edbca7d08cba3c21fc226f419def91d8dfd644f41b9e35c2543ef508a0", + "completedAt": "2026-09-08T09:36:14.768Z" +} diff --git a/TongjiUniApp/build/tang-detective-cleanup-verification.json b/TongjiUniApp/build/tang-detective-cleanup-verification.json new file mode 100644 index 0000000..39c3b7f --- /dev/null +++ b/TongjiUniApp/build/tang-detective-cleanup-verification.json @@ -0,0 +1,135 @@ +{ + "schemaVersion": 1, + "startedAt": "2026-09-08T09:44:21.382Z", + "passed": true, + "uploadRunId": "b9ab703a-49a6-4b6b-ac34-979eca1d4828", + "sourceManifestSha256": "3b203406a31fdbe17c770aeea0bab19755acfedf3fa1595641ee4e15a9712e20", + "mediaManifestSha256": "0062a1edbca7d08cba3c21fc226f419def91d8dfd644f41b9e35c2543ef508a0", + "pruneReceiptSha256": "78844c0335b28c5f91d829ea3178e818a011e8f0c918fbda07330d6c114f8cbd", + "nonMediaSourceFilesByteVerified": 269, + "sourceMediaFilesRemaining": 0, + "removedProjectFiles": 214, + "removedProjectBytes": 24145441, + "recoveryFilesByteVerified": 214, + "commands": [ + { + "command": [ + "npm", + "run", + "test:tang" + ], + "exitCode": 0, + "signal": null, + "stdout": "\n> tongji-uniapp@1.0.0 test:tang\n> node --test build/tang-detective-native-plugin.test.mjs build/tang-detective-cos-media.test.mjs build/tang-detective-source-validation.test.mjs scripts/tang-cos/upload.test.mjs scripts/test-tang-platform.cjs scripts/test-tang-page-lifecycle.cjs\n\nTAP version 13\n# Subtest: verified manifest requires all 204 original paths, hashes, exact HTTPS objects and upload receipts\nok 1 - verified manifest requires all 204 original paths, hashes, exact HTTPS objects and upload receipts\n ---\n duration_ms: 515.341084\n type: 'test'\n ...\n# Subtest: schema 2 accepts only the confirmed COS origin and exact server image / voice object formats\nok 2 - schema 2 accepts only the confirmed COS origin and exact server image / voice object formats\n ---\n duration_ms: 857.29375\n type: 'test'\n ...\n# Subtest: video manifest also requires a true range receipt and its exact immutable object key\nok 3 - video manifest also requires a true range receipt and its exact immutable object key\n ---\n duration_ms: 382.910167\n type: 'test'\n ...\n# Subtest: source byte drift fails even when a manifest still claims the expected hash\nok 4 - source byte drift fails even when a manifest still claims the expected hash\n ---\n duration_ms: 360.020125\n type: 'test'\n ...\n# Subtest: missing manifest stays local; a verified remote import removes only owned output media and remains deterministic\nok 5 - missing manifest stays local; a verified remote import removes only owned output media and remains deterministic\n ---\n duration_ms: 1171.649875\n type: 'test'\n ...\n# Subtest: invalid manifest and externally edited old media are rejected before output mutation\nok 6 - invalid manifest and externally edited old media are rejected before output mutation\n ---\n duration_ms: 503.734542\n type: 'test'\n ...\n# Subtest: all 120 selected comic pages retain source content hashes and 112 formal / 8 provisional status\nok 7 - all 120 selected comic pages retain source content hashes and 112 formal / 8 provisional status\n ---\n duration_ms: 343.70625\n type: 'test'\n ...\n# Subtest: source and adapter static images, cast, C01 player art and all eight listening tracks use exact registry URLs\nok 8 - source and adapter static images, cast, C01 player art and all eight listening tracks use exact registry URLs\n ---\n duration_ms: 303.270584\n type: 'test'\n ...\n# Subtest: remote transport does not open unreviewed audio, promote reserved cues or accept arbitrary HTTPS as approved\nok 9 - remote transport does not open unreviewed audio, promote reserved cues or accept arbitrary HTTPS as approved\n ---\n duration_ms: 299.239625\n type: 'test'\n ...\n# Subtest: asset manager downloads exact URLs with an empty CDN base, verifies downloaded/cache bytes, and rejects substituted URLs\nok 10 - asset manager downloads exact URLs with an empty CDN base, verifies downloaded/cache bytes, and rejects substituted URLs\n ---\n duration_ms: 306.613584\n type: 'test'\n ...\n# Subtest: share preview rehashes cache, fails safely on corruption/network errors, and ignores completion after unload\nok 11 - share preview rehashes cache, fails safely on corruption/network errors, and ignores completion after unload\n ---\n duration_ms: 296.068541\n type: 'test'\n ...\n# Subtest: real-output static validator supports COS mode and rejects extra packaged media\nok 12 - real-output static validator supports COS mode and rejects extra packaged media\n ---\n duration_ms: 490.823\n type: 'test'\n ...\n# Subtest: actual converted chapter image handler exhausts fallbacks and ignores duplicate/late events across page visits\nok 13 - actual converted chapter image handler exhausts fallbacks and ignores duplicate/late events across page visits\n ---\n duration_ms: 333.295083\n type: 'test'\n ...\n# Subtest: output preflight rejects root, file, directory and stale-media symlinks without touching identical external targets\nok 14 - output preflight rejects root, file, directory and stale-media symlinks without touching identical external targets\n ---\n duration_ms: 1646.949375\n type: 'test'\n ...\n# Subtest: original snapshot keeps 473 recorded paths and requires valid remote pruning evidence for any omitted media\nok 15 - original snapshot keeps 473 recorded paths and requires valid remote pruning evidence for any omitted media\n ---\n duration_ms: 47.177917\n type: 'test'\n ...\n# Subtest: path conversion includes dynamic roots and packaged-path regex without changing external URLs or requires\nok 16 - path conversion includes dynamic roots and packaged-path regex without changing external URLs or requires\n ---\n duration_ms: 0.4475\n type: 'test'\n ...\n# Subtest: manifest merge preserves host configuration, supports both package spellings, and rejects conflicting routes\nok 17 - manifest merge preserves host configuration, supports both package spellings, and rejects conflicting routes\n ---\n duration_ms: 1.690208\n type: 'test'\n ...\n# Subtest: all native pages copy into owned output with legal relative requires and unchanged host/media bytes\nok 18 - all native pages copy into owned output with legal relative requires and unchanged host/media bytes\n ---\n duration_ms: 957.362209\n type: 'test'\n ...\n# Subtest: 120 chapter page image selections and C01 full tracks remain in main or their own subpackage\nok 19 - 120 chapter page image selections and C01 full tracks remain in main or their own subpackage\n ---\n duration_ms: 512.390833\n type: 'test'\n ...\n# Subtest: overlay protection and owned cleanup preserve media, host state, and external edits\nok 20 - overlay protection and owned cleanup preserve media, host state, and external edits\n ---\n duration_ms: 999.090709\n type: 'test'\n ...\n# Subtest: page registration rejects ambiguous input and Vite hook remains post-sequential and WeChat-only\nok 21 - page registration rejects ambiguous input and Vite hook remains post-sequential and WeChat-only\n ---\n duration_ms: 0.365042\n type: 'test'\n ...\n# Subtest: the pinned original manifest has 269 nonmedia and exactly 160 JPG / 44 MP3 files\nok 22 - the pinned original manifest has 269 nonmedia and exactly 160 JPG / 44 MP3 files\n ---\n duration_ms: 3.03975\n type: 'test'\n ...\n# Subtest: a fully pruned clone builds and validates from 269 intact files with no external backup access\nok 23 - a fully pruned clone builds and validates from 269 intact files with no external backup access\n ---\n duration_ms: 932.089959\n type: 'test'\n ...\n# Subtest: missing local media and missing remote prune receipt fail before any output changes\nok 24 - missing local media and missing remote prune receipt fail before any output changes\n ---\n duration_ms: 822.711042\n type: 'test'\n ...\n# Subtest: the Vite buildStart preflight rejects missing local media before bundle output can begin\nok 25 - the Vite buildStart preflight rejects missing local media before bundle output can begin\n ---\n duration_ms: 155.042\n type: 'test'\n ...\n# Subtest: pruning needs an exact complete receipt bound to the manifest, upload run, file records and archive hash\nok 26 - pruning needs an exact complete receipt bound to the manifest, upload run, file records and archive hash\n ---\n duration_ms: 599.077208\n type: 'test'\n ...\n# Subtest: remote pruning never allows missing nonmedia, changed surviving bytes or extra source files\nok 27 - remote pruning never allows missing nonmedia, changed surviving bytes or extra source files\n ---\n duration_ms: 185.848208\n type: 'test'\n ...\n# Subtest: source and evidence symlinks, including dangling media links, cannot authorize or hide omission\nok 28 - source and evidence symlinks, including dangling media links, cannot authorize or hide omission\n ---\n duration_ms: 149.757875\n type: 'test'\n ...\n# Subtest: remote output validation rejects later receipt drift or removal\nok 29 - remote output validation rejects later receipt drift or removal\n ---\n duration_ms: 636.244458\n type: 'test'\n ...\n# Subtest: inventory creates content-addressed keys without changing source files\nok 30 - inventory creates content-addressed keys without changing source files\n ---\n duration_ms: 5.051666\n type: 'test'\n ...\n# Subtest: inventory refuses changed bytes, unrecorded files, and symlinks\nok 31 - inventory refuses changed bytes, unrecorded files, and symlinks\n ---\n duration_ms: 14.88\n type: 'test'\n ...\n# Subtest: database config never disables certificate or hostname verification\nok 32 - database config never disables certificate or hostname verification\n ---\n duration_ms: 1.515333\n type: 'test'\n ...\n# Subtest: destination rejects insecure/signed URLs and missing credentials\nok 33 - destination rejects insecure/signed URLs and missing credentials\n ---\n duration_ms: 0.336708\n type: 'test'\n ...\n# Subtest: object URLs only use namespaced content-addressed keys\nok 34 - object URLs only use namespaced content-addressed keys\n ---\n duration_ms: 0.293334\n type: 'test'\n ...\n# Subtest: public verification checks complete bytes, MIME, hash and audio Range\nok 35 - public verification checks complete bytes, MIME, hash and audio Range\n ---\n duration_ms: 20.89125\n type: 'test'\n ...\n# Subtest: private objects, oversize bodies, corrupt data and missing Range cannot activate a manifest\nok 36 - private objects, oversize bodies, corrupt data and missing Range cannot activate a manifest\n ---\n duration_ms: 2.0405\n type: 'test'\n ...\n# Subtest: safe errors never leak raw connection or signed-URL text\nok 37 - safe errors never leak raw connection or signed-URL text\n ---\n duration_ms: 0.201458\n type: 'test'\n ...\n# Subtest: uploader only creates missing objects with checksums and never changes permissions\nok 38 - uploader only creates missing objects with checksums and never changes permissions\n ---\n duration_ms: 0.34\n type: 'test'\n ...\n# Subtest: uploader reuses exact owned objects; mismatches and denied HEAD never trigger writes\nok 39 - uploader reuses exact owned objects; mismatches and denied HEAD never trigger writes\n ---\n duration_ms: 0.399292\n type: 'test'\n ...\n# Subtest: database deadline aborts a stalled task-owned operation and clears its timer\nok 40 - database deadline aborts a stalled task-owned operation and clears its timer\n ---\n duration_ms: 5.153\n type: 'test'\n ...\n# Subtest: a configuration failure creates a fresh failed attempt and archives previous success\nok 41 - a configuration failure creates a fresh failed attempt and archives previous success\n ---\n duration_ms: 5.667375\n type: 'test'\n ...\n# Subtest: SDK initialization failure is recorded without exposing credentials\nok 42 - SDK initialization failure is recorded without exposing credentials\n ---\n duration_ms: 5.034209\n type: 'test'\n ...\n# Subtest: lost PUT response persists unknown outcome and only performs read-only reconciliation\nok 43 - lost PUT response persists unknown outcome and only performs read-only reconciliation\n ---\n duration_ms: 5.316125\n type: 'test'\n ...\n# Subtest: only a fully verified upload writes the activation manifest; no secret values reach artifacts\nok 44 - only a fully verified upload writes the activation manifest; no secret values reach artifacts\n ---\n duration_ms: 6.337209\n type: 'test'\n ...\n# Subtest: deferred lifecycle delivers onLoad -> onShow -> onReady once and guards custom onX events\nok 45 - deferred lifecycle delivers onLoad -> onShow -> onReady once and guards custom onX events\n ---\n duration_ms: 6.246958\n type: 'test'\n ...\n# Subtest: package-audio-c01-a: real onReady waits for _page and unload clears both timers, context, and listeners\nok 46 - package-audio-c01-a: real onReady waits for _page and unload clears both timers, context, and listeners\n ---\n duration_ms: 4.879042\n type: 'test'\n ...\n# Subtest: package-audio-c01-a: hidden boot never initializes audio until return; changed-account hide retires old audio\nok 47 - package-audio-c01-a: hidden boot never initializes audio until return; changed-account hide retires old audio\n ---\n duration_ms: 3.206458\n type: 'test'\n ...\n# Subtest: package-audio-c01-b: real onReady waits for _page and unload clears both timers, context, and listeners\nok 48 - package-audio-c01-b: real onReady waits for _page and unload clears both timers, context, and listeners\n ---\n duration_ms: 4.301458\n type: 'test'\n ...\n# Subtest: package-audio-c01-b: hidden boot never initializes audio until return; changed-account hide retires old audio\nok 49 - package-audio-c01-b: hidden boot never initializes audio until return; changed-account hide retires old audio\n ---\n duration_ms: 2.824458\n type: 'test'\n ...\n# Subtest: real chapter unload destroys audio and stale callbacks cannot save or update\nok 50 - real chapter unload destroys audio and stale callbacks cannot save or update\n ---\n duration_ms: 21.170791\n type: 'test'\n ...\n# Subtest: real chapter account-change hide cleans resources without writing previous audio progress into next account\nok 51 - real chapter account-change hide cleans resources without writing previous audio progress into next account\n ---\n duration_ms: 14.025583\n type: 'test'\n ...\n# Subtest: unload before hydration prevents late onLoad/onShow/onReady and playback\nok 52 - unload before hydration prevents late onLoad/onShow/onReady and playback\n ---\n duration_ms: 1.374292\n type: 'test'\n ...\n# Subtest: catalog reset requires unchanged account and the same visible page lifetime\nok 53 - catalog reset requires unchanged account and the same visible page lifetime\n ---\n duration_ms: 7.785875\n type: 'test'\n ...\n# Subtest: cleanup permission ends synchronously even when the original unload throws\nok 54 - cleanup permission ends synchronously even when the original unload throws\n ---\n duration_ms: 1.941917\n type: 'test'\n ...\n# Subtest: SHA-256 uses the original portable implementation\nok 55 - SHA-256 uses the original portable implementation\n ---\n duration_ms: 1.526208\n type: 'test'\n ...\n# Subtest: wire projection excludes story text, health answers, credentials and invalid IDs\nok 56 - wire projection excludes story text, health answers, credentials and invalid IDs\n ---\n duration_ms: 0.784292\n type: 'test'\n ...\n# Subtest: guest can read locally without any API calls, and guest state is not uploaded after login\nok 57 - guest can read locally without any API calls, and guest state is not uploaded after login\n ---\n duration_ms: 3.606666\n type: 'test'\n ...\n# Subtest: authenticated save uses existing token header, JSON, whitelist and own revision\nok 58 - authenticated save uses existing token header, JSON, whitelist and own revision\n ---\n duration_ms: 1.561333\n type: 'test'\n ...\n# Subtest: offline edits survive and retry when API becomes available\nok 59 - offline edits survive and retry when API becomes available\n ---\n duration_ms: 1.003208\n type: 'test'\n ...\n# Subtest: login expiration never silently claims synchronization\nok 60 - login expiration never silently claims synchronization\n ---\n duration_ms: 1.864834\n type: 'test'\n ...\n# Subtest: account changes isolate progress and reject delayed writes from the previous page\nok 61 - account changes isolate progress and reject delayed writes from the previous page\n ---\n duration_ms: 1.11325\n type: 'test'\n ...\n# Subtest: late HTTP response after account switch does not hydrate the new account\nok 62 - late HTTP response after account switch does not hydrate the new account\n ---\n duration_ms: 1.228542\n type: 'test'\n ...\n# Subtest: conflict keeps both versions; explicit cloud choice takes a recoverable backup\nok 63 - conflict keeps both versions; explicit cloud choice takes a recoverable backup\n ---\n duration_ms: 0.754875\n type: 'test'\n ...\n# Subtest: explicit local conflict resolution uses the new server revision\nok 64 - explicit local conflict resolution uses the new server revision\n ---\n duration_ms: 0.671084\n type: 'test'\n ...\n# Subtest: lost response retries the same request ID and does not double increment\nok 65 - lost response retries the same request ID and does not double increment\n ---\n duration_ms: 0.640875\n type: 'test'\n ...\n# Subtest: new edits made while saving are queued without being overwritten by an old response\nok 66 - new edits made while saving are queued without being overwritten by an old response\n ---\n duration_ms: 0.651708\n type: 'test'\n ...\n# Subtest: reset uses an empty story and preserves valid unsynced card IDs\nok 67 - reset uses an empty story and preserves valid unsynced card IDs\n ---\n duration_ms: 0.346458\n type: 'test'\n ...\n# Subtest: reset requested during an in-flight replace is not dropped\nok 68 - reset requested during an in-flight replace is not dropped\n ---\n duration_ms: 0.374583\n type: 'test'\n ...\n# Subtest: storage errors stop cloud writes and are reported truthfully\nok 69 - storage errors stop cloud writes and are reported truthfully\n ---\n duration_ms: 0.325792\n type: 'test'\n ...\n# Subtest: settings/audio remain local and scoped\nok 70 - settings/audio remain local and scoped\n ---\n duration_ms: 0.252792\n type: 'test'\n ...\n# Subtest: a renewed token recovers the authenticated user local unsynced queue\nok 71 - a renewed token recovers the authenticated user local unsynced queue\n ---\n duration_ms: 0.916417\n type: 'test'\n ...\n# Subtest: cloud hydration does not claim local persistence when storage is full\nok 72 - cloud hydration does not claim local persistence when storage is full\n ---\n duration_ms: 0.3085\n type: 'test'\n ...\n# Subtest: permanent contract failure is not automatically resubmitted by page hide or new edits\nok 73 - permanent contract failure is not automatically resubmitted by page hide or new edits\n ---\n duration_ms: 1.086042\n type: 'test'\n ...\n# Subtest: review regression: offline reset then new reading sends reset followed by the new progress\nok 74 - review regression: offline reset then new reading sends reset followed by the new progress\n ---\n duration_ms: 0.819792\n type: 'test'\n ...\n# Subtest: review regression: queue persistence failure releases in-flight lock and can recover\nok 75 - review regression: queue persistence failure releases in-flight lock and can recover\n ---\n duration_ms: 1.301584\n type: 'test'\n ...\n# Subtest: review regression: conflict confirmation is bound to account and exact local/conflict revision\nok 76 - review regression: conflict confirmation is bound to account and exact local/conflict revision\n ---\n duration_ms: 0.447833\n type: 'test'\n ...\n# Subtest: review regression: reset helper rejects missing or stale confirmation scope\nok 77 - review regression: reset helper rejects missing or stale confirmation scope\n ---\n duration_ms: 1.890625\n type: 'test'\n ...\n# Subtest: native lifecycle waits for account hydration and blocks pre-boot interaction\nok 78 - native lifecycle waits for account hydration and blocks pre-boot interaction\n ---\n duration_ms: 8.846166\n type: 'test'\n ...\n# Subtest: native page hidden during boot initializes only on return, then cleans up\nok 79 - native page hidden during boot initializes only on return, then cleans up\n ---\n duration_ms: 1.261666\n type: 'test'\n ...\n# Subtest: native page unloaded before HTTP completion cannot start late playback/initialization\nok 80 - native page unloaded before HTTP completion cannot start late playback/initialization\n ---\n duration_ms: 0.699667\n type: 'test'\n ...\n# Subtest: native event after host account changes returns home before old game mutation\nok 81 - native event after host account changes returns home before old game mutation\n ---\n duration_ms: 1.190667\n type: 'test'\n ...\n1..81\n# tests 81\n# suites 0\n# pass 81\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 7876.523958\n", + "stderr": "", + "passed": true + }, + { + "command": [ + "npm", + "run", + "build:mp-weixin" + ], + "exitCode": 0, + "signal": null, + "stdout": "\n> tongji-uniapp@1.0.0 build:mp-weixin\n> cross-env UNI_INPUT_DIR=. uni build -p mp-weixin\n\nCompiling...\n\nuni-app 有新版本发布,请执行 `npx @dcloudio/uvm@latest` 更新,更新日志详见:https://download1.dcloud.net.cn/hbuilderx/changelog/5.24.2026081301.html\n​[plugin:tang-detective-native-pages] 唐侦探原生页面已合并:24 页,原生文件 6421924 bytes,其中主包新增 548319 bytes;此结果不代表包体积或发布验收通过。​\nDONE Build complete.\nRun method: open Weixin Mini Program Devtools, import dist/build/mp-weixin run.\n", + "stderr": "", + "passed": true + }, + { + "command": [ + "npm", + "run", + "check:tang-output" + ], + "exitCode": 0, + "signal": null, + "stdout": "\n> tongji-uniapp@1.0.0 check:tang-output\n> node build/validate-tang-detective-output.mjs dist/build/mp-weixin && node build/validate-tang-tongji-host.mjs dist/build/mp-weixin\n\n{\n \"passed\": true,\n \"nativePages\": 24,\n \"mediaFiles\": 204,\n \"packagedMediaFiles\": 0,\n \"mediaMode\": \"cos\",\n \"relativeDependencies\": 525,\n \"nativeSizes\": {\n \"sourceFileBytes\": 6421924,\n \"mainPackageBytes\": 548319,\n \"subPackages\": {\n \"tang-detective/package-audio-c01-a\": 31241,\n \"tang-detective/package-audio-c01-b\": 31174,\n \"tang-detective/package-audio-player\": 124896,\n \"tang-detective/package-chapter-02\": 369451,\n \"tang-detective/package-chapter-03\": 369451,\n \"tang-detective/package-chapter-04\": 374841,\n \"tang-detective/package-chapter-05\": 369451,\n \"tang-detective/package-chapter-06\": 375496,\n \"tang-detective/package-chapter-07\": 375689,\n \"tang-detective/package-chapter-08\": 375888,\n \"tang-detective/package-chapter-09\": 377073,\n \"tang-detective/package-chapter-10\": 376159,\n \"tang-detective/package-chapter-11\": 376026,\n \"tang-detective/package-chapter-12\": 376689,\n \"tang-detective/package-chapter-13\": 376080,\n \"tang-detective/package-chapter-14\": 375985,\n \"tang-detective/package-chapter-15\": 376289,\n \"tang-detective/package-game\": 441726\n },\n \"mediaFileCount\": 0,\n \"mediaBytes\": 0\n },\n \"outputSizes\": {\n \"totalFileBytes\": 7438230,\n \"mainPackageFileBytes\": 1412653,\n \"subPackages\": {\n \"tang-detective/package-audio-c01-a\": 31241,\n \"tang-detective/package-audio-c01-b\": 31174,\n \"tang-detective/package-audio-player\": 124896,\n \"tang-detective/package-chapter-02\": 369451,\n \"tang-detective/package-chapter-03\": 369451,\n \"tang-detective/package-chapter-04\": 374841,\n \"tang-detective/package-chapter-05\": 369451,\n \"tang-detective/package-chapter-06\": 375496,\n \"tang-detective/package-chapter-07\": 375689,\n \"tang-detective/package-chapter-08\": 375888,\n \"tang-detective/package-chapter-09\": 377073,\n \"tang-detective/package-chapter-10\": 376159,\n \"tang-detective/package-chapter-11\": 376026,\n \"tang-detective/package-chapter-12\": 376689,\n \"tang-detective/package-chapter-13\": 376080,\n \"tang-detective/package-chapter-14\": 375985,\n \"tang-detective/package-chapter-15\": 376289,\n \"tang-detective/package-game\": 441726,\n \"training\": 151972\n }\n },\n \"errors\": [],\n \"boundary\": \"Static source/asset/dependency/manifest verification only. Raw file sizes are not WeChat upload package sizes; no simulator, device, audio listening, upload, or release acceptance was performed.\"\n}\n{\n \"passed\": true,\n \"project\": \"TongjiUniApp\",\n \"startupPage\": \"tongji/pages/weekly\",\n \"sourceHostRoutes\": 15,\n \"nativePages\": 24,\n \"apiBaseUrl\": \"https://xt.zhenyangtang.com.cn/\",\n \"unrelatedCallSdkImported\": false,\n \"boundary\": \"Built output routing/configuration checks only; not a live backend or device test.\"\n}\n", + "stderr": "", + "passed": true + }, + { + "command": [ + "/Users/dagedagededagege/.hermes/node/bin/node", + "scripts/check-tang-native-compiler.cjs" + ], + "exitCode": 0, + "signal": null, + "stdout": "{\n \"nativePages\": 24,\n \"results\": [\n {\n \"tool\": \"wcc\",\n \"exitCode\": 0,\n \"passed\": true,\n \"generatedOutputBytes\": 1192002,\n \"diagnostics\": \"\",\n \"error\": null\n },\n {\n \"tool\": \"wcsc\",\n \"exitCode\": 0,\n \"passed\": true,\n \"generatedOutputBytes\": 1346916,\n \"diagnostics\": \"\",\n \"error\": null\n }\n ],\n \"boundary\": \"Installed compiler syntax check only, not simulator/device, networking or upload acceptance.\"\n}\n", + "stderr": "", + "passed": true + } + ], + "boundary": "Actual cleaned checkout, installed compiler and offline automated tests only. Not real-device, medical, backend deployment or release acceptance.", + "output": { + "passed": true, + "nativePages": 24, + "mediaFiles": 204, + "packagedMediaFiles": 0, + "mediaMode": "cos", + "relativeDependencies": 525, + "nativeSizes": { + "sourceFileBytes": 6421924, + "mainPackageBytes": 548319, + "subPackages": { + "tang-detective/package-audio-c01-a": 31241, + "tang-detective/package-audio-c01-b": 31174, + "tang-detective/package-audio-player": 124896, + "tang-detective/package-chapter-02": 369451, + "tang-detective/package-chapter-03": 369451, + "tang-detective/package-chapter-04": 374841, + "tang-detective/package-chapter-05": 369451, + "tang-detective/package-chapter-06": 375496, + "tang-detective/package-chapter-07": 375689, + "tang-detective/package-chapter-08": 375888, + "tang-detective/package-chapter-09": 377073, + "tang-detective/package-chapter-10": 376159, + "tang-detective/package-chapter-11": 376026, + "tang-detective/package-chapter-12": 376689, + "tang-detective/package-chapter-13": 376080, + "tang-detective/package-chapter-14": 375985, + "tang-detective/package-chapter-15": 376289, + "tang-detective/package-game": 441726 + }, + "mediaFileCount": 0, + "mediaBytes": 0 + }, + "outputSizes": { + "totalFileBytes": 7438230, + "mainPackageFileBytes": 1412653, + "subPackages": { + "tang-detective/package-audio-c01-a": 31241, + "tang-detective/package-audio-c01-b": 31174, + "tang-detective/package-audio-player": 124896, + "tang-detective/package-chapter-02": 369451, + "tang-detective/package-chapter-03": 369451, + "tang-detective/package-chapter-04": 374841, + "tang-detective/package-chapter-05": 369451, + "tang-detective/package-chapter-06": 375496, + "tang-detective/package-chapter-07": 375689, + "tang-detective/package-chapter-08": 375888, + "tang-detective/package-chapter-09": 377073, + "tang-detective/package-chapter-10": 376159, + "tang-detective/package-chapter-11": 376026, + "tang-detective/package-chapter-12": 376689, + "tang-detective/package-chapter-13": 376080, + "tang-detective/package-chapter-14": 375985, + "tang-detective/package-chapter-15": 376289, + "tang-detective/package-game": 441726, + "training": 151972 + } + }, + "errors": [], + "boundary": "Static source/asset/dependency/manifest verification only. Raw file sizes are not WeChat upload package sizes; no simulator, device, audio listening, upload, or release acceptance was performed." + }, + "remainingHostMedia": [ + "static/images/my/after-sale.png", + "static/images/my/delivery.png", + "static/images/my/payment.png", + "static/images/my/review.png", + "static/images/my/shipping.png", + "static/user/user.png", + "static/user/user_no.png" + ], + "completedAt": "2026-09-08T09:44:35.660Z" +} diff --git a/TongjiUniApp/build/tang-detective-cos-manifest.json b/TongjiUniApp/build/tang-detective-cos-manifest.json new file mode 100644 index 0000000..b05ad27 --- /dev/null +++ b/TongjiUniApp/build/tang-detective-cos-manifest.json @@ -0,0 +1,2868 @@ +{ + "schemaVersion": 2, + "uploadRunId": "b9ab703a-49a6-4b6b-ac34-979eca1d4828", + "sourceManifestSha256": "3b203406a31fdbe17c770aeea0bab19755acfedf3fa1595641ee4e15a9712e20", + "destination": { + "bucket": "gz-1349751149", + "region": "ap-guangzhou", + "baseUrl": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com" + }, + "entries": [ + { + "sourcePath": "assets/characters/female-cook.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 11958, + "sha256": "c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f", + "objectKey": "uploads/images/20260908/20260908172953806b83817.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172953806b83817.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.290Z" + }, + { + "sourcePath": "assets/characters/lele.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 102681, + "sha256": "807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168", + "objectKey": "uploads/images/20260908/20260908172954a3c700294.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954a3c700294.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.339Z" + }, + { + "sourcePath": "assets/characters/lin-xiulan.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 107355, + "sha256": "56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361", + "objectKey": "uploads/images/20260908/202609081729547cbb37718.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081729547cbb37718.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.370Z" + }, + { + "sourcePath": "assets/characters/qin-xiaoman.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 92914, + "sha256": "22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5", + "objectKey": "uploads/images/20260908/2026090817295416f2f2841.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817295416f2f2841.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.337Z" + }, + { + "sourcePath": "assets/characters/qin-zhicheng.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 116799, + "sha256": "7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2", + "objectKey": "uploads/images/20260908/202609081729541a4a74577.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081729541a4a74577.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.629Z" + }, + { + "sourcePath": "assets/characters/tang-mingyuan.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 92880, + "sha256": "5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb", + "objectKey": "uploads/images/20260908/20260908172954fef263003.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954fef263003.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.537Z" + }, + { + "sourcePath": "assets/characters/tang-shouan.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 108663, + "sha256": "4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89", + "objectKey": "uploads/images/20260908/20260908172954a75561985.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954a75561985.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.631Z" + }, + { + "sourcePath": "assets/characters/xiaozhen.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 89720, + "sha256": "d5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb", + "objectKey": "uploads/images/20260908/20260908172954578d88427.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954578d88427.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.828Z" + }, + { + "sourcePath": "assets/characters/zhao-jianguo.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 100165, + "sha256": "1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0", + "objectKey": "uploads/images/20260908/2026090817295420b991364.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817295420b991364.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.760Z" + }, + { + "sourcePath": "assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 97655, + "sha256": "f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0", + "objectKey": "uploads/images/20260908/20260908172954af5606377.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954af5606377.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.749Z" + }, + { + "sourcePath": "assets/scenes/gui-xiang-1978.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "objectKey": "uploads/images/20260908/2026090817303974c831720.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303974c831720.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.868Z" + }, + { + "sourcePath": "assets/scenes/gui-xiang-1995.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 67246, + "sha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f", + "objectKey": "uploads/images/20260908/20260908173040de54b5498.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173040de54b5498.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.897Z" + }, + { + "sourcePath": "assets/scenes/gui-xiang-1998.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 62727, + "sha256": "d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14", + "objectKey": "uploads/images/20260908/2026090817303983bc35301.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303983bc35301.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.958Z" + }, + { + "sourcePath": "assets/scenes/gui-xiang-2001.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 59408, + "sha256": "9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8", + "objectKey": "uploads/images/20260908/20260908173039ad5bc2437.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039ad5bc2437.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.969Z" + }, + { + "sourcePath": "assets/scenes/gui-xiang-2003.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 53822, + "sha256": "68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431", + "objectKey": "uploads/images/20260908/2026090817303985ee07761.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303985ee07761.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.986Z" + }, + { + "sourcePath": "assets/scenes/gui-xiang-2008.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 53286, + "sha256": "c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5", + "objectKey": "uploads/images/20260908/2026090817303994b4b1401.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303994b4b1401.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.021Z" + }, + { + "sourcePath": "assets/scenes/gui-xiang-2026.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "objectKey": "uploads/images/20260908/20260908173039628910741.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039628910741.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.155Z" + }, + { + "sourcePath": "assets/share/guixiang-story-share-preview-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 99232, + "sha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69", + "objectKey": "uploads/images/20260908/202609081718480ee488780.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081718480ee488780.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.157Z" + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P01.daa8f04a2627.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 270139, + "sha256": "daa8f04a262767109f3a0621f178e3c58f169507e8ea09fa8d583258cb713282", + "objectKey": "uploads/voice/20260908/1788859559652-e90yvvdk.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859559652-e90yvvdk.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "daa8f04a262767109f3a0621f178e3c58f169507e8ea09fa8d583258cb713282", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.330Z" + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P02.7774981b1878.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 396572, + "sha256": "7774981b1878f6ad882cb7a4e3f923d67f43c12c2ea068982df1cab63820d8c2", + "objectKey": "uploads/voice/20260908/1788859640935-23ixzy68.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859640935-23ixzy68.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7774981b1878f6ad882cb7a4e3f923d67f43c12c2ea068982df1cab63820d8c2", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.349Z" + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P03.0f21daa7eccb.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 244853, + "sha256": "0f21daa7eccbaac9139317e8209570fe025fb2403ea5cc794e12586764365885", + "objectKey": "uploads/voice/20260908/1788859681917-n7wb94nh.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859681917-n7wb94nh.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "0f21daa7eccbaac9139317e8209570fe025fb2403ea5cc794e12586764365885", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.433Z" + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P04.ed65c367d761.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 375047, + "sha256": "ed65c367d76125e1404d76bb86faa0629b599f6853a39e58821617f854bfa167", + "objectKey": "uploads/voice/20260908/1788859683117-o8y8c1ap.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859683117-o8y8c1ap.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "ed65c367d76125e1404d76bb86faa0629b599f6853a39e58821617f854bfa167", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.521Z" + }, + { + "sourcePath": "package-audio-c01-a/assets/player-art/S01-C01-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 105278, + "sha256": "12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a", + "objectKey": "uploads/images/20260908/20260908173039668f11872.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039668f11872.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.577Z" + }, + { + "sourcePath": "package-audio-c01-a/assets/player-art/S01-C01-P02-missing-table-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 97655, + "sha256": "f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0", + "objectKey": "uploads/images/20260908/20260908172954af5606377.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954af5606377.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.749Z" + }, + { + "sourcePath": "package-audio-c01-a/assets/player-art/S01-C01-P03-H01-scan-only-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 91819, + "sha256": "6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80", + "objectKey": "uploads/images/20260908/202609081730407a8a27957.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081730407a8a27957.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.482Z" + }, + { + "sourcePath": "package-audio-c01-a/assets/player-art/S01-C01-P04-H02-extra-dish-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 120832, + "sha256": "bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce", + "objectKey": "uploads/images/20260908/202609081730409483f1003.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081730409483f1003.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.546Z" + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P05.210005d24e32.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 263661, + "sha256": "210005d24e3276e2689e38b0bb3a9d6b6e900ca3cc5ca874278a0b89a50def6a", + "objectKey": "uploads/voice/20260908/1788859684272-vx02qyha.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859684272-vx02qyha.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "210005d24e3276e2689e38b0bb3a9d6b6e900ca3cc5ca874278a0b89a50def6a", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.829Z" + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P06.5e69ed4c17f3.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 107344, + "sha256": "5e69ed4c17f3d5c4168546c0b76a6d5bfb13ecbecdd982c5be1c27259b9ce1a4", + "objectKey": "uploads/voice/20260908/1788859685309-bq8hczp2.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859685309-bq8hczp2.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "5e69ed4c17f3d5c4168546c0b76a6d5bfb13ecbecdd982c5be1c27259b9ce1a4", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.874Z" + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P07.a68bc0cb8ecc.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 84356, + "sha256": "a68bc0cb8ecc6d53b7c877567d6c21fdd894c914e84c072357116429d05d0cc5", + "objectKey": "uploads/voice/20260908/1788859686361-f4myg1jo.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859686361-f4myg1jo.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "a68bc0cb8ecc6d53b7c877567d6c21fdd894c914e84c072357116429d05d0cc5", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.783Z" + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P08.1c44eff9d71f.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 316951, + "sha256": "1c44eff9d71fcd004e53050e0727ee37fa3560509d3579c0e2cd004e80e246ec", + "objectKey": "uploads/voice/20260908/1788859687423-at9k5kro.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859687423-at9k5kro.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "1c44eff9d71fcd004e53050e0727ee37fa3560509d3579c0e2cd004e80e246ec", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:05.832Z" + }, + { + "sourcePath": "package-audio-c01-b/assets/player-art/S01-C01-P05-H03-four-imprints-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 111834, + "sha256": "d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f", + "objectKey": "uploads/images/20260908/2026090817310719bb62047.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817310719bb62047.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.911Z" + }, + { + "sourcePath": "package-audio-c01-b/assets/player-art/S01-C01-P06-H04-unsaid-speech-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 120294, + "sha256": "7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750", + "objectKey": "uploads/images/20260908/202609081731073a6bf7270.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731073a6bf7270.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.975Z" + }, + { + "sourcePath": "package-audio-c01-b/assets/player-art/S01-C01-P07-EM01-call-zhao-in-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 96418, + "sha256": "4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31", + "objectKey": "uploads/images/20260908/20260908173107b3c447822.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173107b3c447822.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.964Z" + }, + { + "sourcePath": "package-audio-c01-b/assets/player-art/S01-C01-P08-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 99232, + "sha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69", + "objectKey": "uploads/images/20260908/202609081718480ee488780.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081718480ee488780.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.157Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS001.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 19125, + "sha256": "580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233", + "objectKey": "uploads/voice/20260908/1788859688761-rv0vx59b.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859688761-rv0vx59b.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.288Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS002-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4797, + "sha256": "aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8", + "objectKey": "uploads/voice/20260908/1788859689813-2u5eyx28.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859689813-2u5eyx28.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.241Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS002.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6417, + "sha256": "ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe", + "objectKey": "uploads/voice/20260908/1788859705645-v0njwhr6.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859705645-v0njwhr6.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.282Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS003.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 19053, + "sha256": "3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f", + "objectKey": "uploads/voice/20260908/1788859706721-j2czunoh.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859706721-j2czunoh.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.203Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS004.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 8505, + "sha256": "e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c", + "objectKey": "uploads/voice/20260908/1788859707781-5eif2sva.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859707781-5eif2sva.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.428Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS005.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 7749, + "sha256": "6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e", + "objectKey": "uploads/voice/20260908/1788859708857-lgkflk7r.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859708857-lgkflk7r.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.475Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS006.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 12465, + "sha256": "067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88", + "objectKey": "uploads/voice/20260908/1788859709963-w40dseme.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859709963-w40dseme.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.521Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS007.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 12213, + "sha256": "7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c", + "objectKey": "uploads/voice/20260908/1788859710994-9ja52nj4.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859710994-9ja52nj4.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.508Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS008.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 12465, + "sha256": "288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f", + "objectKey": "uploads/voice/20260908/1788859712054-pqgtblz7.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859712054-pqgtblz7.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.729Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS009.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6993, + "sha256": "5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2", + "objectKey": "uploads/voice/20260908/1788859713116-rtg8ou4j.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859713116-rtg8ou4j.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.731Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS010.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 27189, + "sha256": "d371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d", + "objectKey": "uploads/voice/20260908/1788859714260-41ehj12t.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859714260-41ehj12t.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.738Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS011.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 15741, + "sha256": "2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb", + "objectKey": "uploads/voice/20260908/1788859715307-zvzm6c52.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859715307-zvzm6c52.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.824Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS012.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 32697, + "sha256": "33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a", + "objectKey": "uploads/voice/20260908/1788859716357-ddolkq84.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859716357-ddolkq84.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.949Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MT000.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4005, + "sha256": "5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e", + "objectKey": "uploads/voice/20260908/1788859717407-joxtjydj.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859717407-joxtjydj.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.097Z" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-TE900.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 7713, + "sha256": "d57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1", + "objectKey": "uploads/voice/20260908/1788859718453-7taqslda.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859718453-7taqslda.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:06.963Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 103969, + "sha256": "c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552", + "objectKey": "uploads/images/20260908/202609081731074bb984948.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731074bb984948.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:06.946Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128391, + "sha256": "892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea", + "objectKey": "uploads/images/20260908/202609081731073a9ad6102.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731073a9ad6102.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:07.088Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 110191, + "sha256": "e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c", + "objectKey": "uploads/images/20260908/202609081731076ef7d1167.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731076ef7d1167.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:07.101Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 125153, + "sha256": "ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593", + "objectKey": "uploads/images/20260908/202609081731075b3e30712.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731075b3e30712.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:07.096Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128039, + "sha256": "cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d", + "objectKey": "uploads/images/20260908/202609081731072dfe55233.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731072dfe55233.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:07.787Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 99846, + "sha256": "61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541", + "objectKey": "uploads/images/20260908/202609081731078ac2d7004.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731078ac2d7004.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:07.258Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 133519, + "sha256": "c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539", + "objectKey": "uploads/images/20260908/20260908173107466700017.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173107466700017.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:07.303Z" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128463, + "sha256": "6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703", + "objectKey": "uploads/images/20260908/20260908173112ff8887242.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112ff8887242.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:07.254Z" + }, + { + "sourcePath": "package-chapter-02/assets/scenes/gui-xiang-1978.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "objectKey": "uploads/images/20260908/2026090817303974c831720.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303974c831720.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.868Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS001.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 25821, + "sha256": "b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43", + "objectKey": "uploads/voice/20260908/1788859719556-4h5g177p.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859719556-4h5g177p.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.480Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS002-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 10557, + "sha256": "ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd", + "objectKey": "uploads/voice/20260908/1788859720601-tm2rratq.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859720601-tm2rratq.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.497Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS002.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6489, + "sha256": "c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c", + "objectKey": "uploads/voice/20260908/1788859735790-h24dj5jw.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859735790-h24dj5jw.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.530Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS003.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 20205, + "sha256": "af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184", + "objectKey": "uploads/voice/20260908/1788859736846-tu8raupk.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859736846-tu8raupk.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.780Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS004.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 7749, + "sha256": "c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da", + "objectKey": "uploads/voice/20260908/1788859738725-lzs4obsj.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859738725-lzs4obsj.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.775Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS005-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 5517, + "sha256": "4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e", + "objectKey": "uploads/voice/20260908/1788859739954-0sdopsv6.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859739954-0sdopsv6.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.785Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS005.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 10053, + "sha256": "46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442", + "objectKey": "uploads/voice/20260908/1788859741040-tnxabkmf.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859741040-tnxabkmf.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:07.981Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS006.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 13401, + "sha256": "2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5", + "objectKey": "uploads/voice/20260908/1788859742080-q1gq96gr.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859742080-q1gq96gr.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.014Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS007-ATTR.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6309, + "sha256": "f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0", + "objectKey": "uploads/voice/20260908/1788859743113-kgit8i2r.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859743113-kgit8i2r.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.020Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS007.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6849, + "sha256": "68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038", + "objectKey": "uploads/voice/20260908/1788859744174-pg41290p.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859744174-pg41290p.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.003Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS008.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 5373, + "sha256": "d9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7", + "objectKey": "uploads/voice/20260908/1788859745258-2u7m9wc0.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859745258-2u7m9wc0.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.562Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS009.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 20925, + "sha256": "97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed", + "objectKey": "uploads/voice/20260908/1788859746348-qrqomq07.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859746348-qrqomq07.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.395Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS010.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6165, + "sha256": "49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0", + "objectKey": "uploads/voice/20260908/1788859747502-csqqmkys.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859747502-csqqmkys.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.403Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS011.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 15777, + "sha256": "6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25", + "objectKey": "uploads/voice/20260908/1788859748680-hx5gjcjt.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859748680-hx5gjcjt.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.498Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS013.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 19017, + "sha256": "8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a", + "objectKey": "uploads/voice/20260908/1788859749772-njzzx6f8.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859749772-njzzx6f8.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.625Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS014.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4869, + "sha256": "8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0", + "objectKey": "uploads/voice/20260908/1788859750856-ubrs0bjv.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859750856-ubrs0bjv.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.613Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS015.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 14193, + "sha256": "661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb", + "objectKey": "uploads/voice/20260908/1788859751906-i84qzugn.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859751906-i84qzugn.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.699Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MT000.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 4077, + "sha256": "374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65", + "objectKey": "uploads/voice/20260908/1788859752961-k632st94.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859752961-k632st94.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.821Z" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-TE900.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 6813, + "sha256": "f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993", + "objectKey": "uploads/voice/20260908/1788859754110-nyj2b3le.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859754110-nyj2b3le.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:08.918Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 118129, + "sha256": "7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4", + "objectKey": "uploads/images/20260908/20260908173112e49c90827.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112e49c90827.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:08.823Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 194945, + "sha256": "feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5", + "objectKey": "uploads/images/20260908/20260908173112365a59120.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112365a59120.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:08.847Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 207111, + "sha256": "841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e", + "objectKey": "uploads/images/20260908/20260908173112d30437983.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112d30437983.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:08.975Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 199826, + "sha256": "8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727", + "objectKey": "uploads/images/20260908/202609081731127140b3844.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731127140b3844.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:08.978Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 171496, + "sha256": "cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79", + "objectKey": "uploads/images/20260908/20260908173112353247130.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112353247130.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:08.971Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 84540, + "sha256": "00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1", + "objectKey": "uploads/images/20260908/20260908173112169de0256.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112169de0256.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.038Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 182868, + "sha256": "787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602", + "objectKey": "uploads/images/20260908/20260908173112a505f4772.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112a505f4772.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.126Z" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 210097, + "sha256": "056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5", + "objectKey": "uploads/images/20260908/202609081731122b3399354.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731122b3399354.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.119Z" + }, + { + "sourcePath": "package-chapter-03/assets/scenes/gui-xiang-1978.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "objectKey": "uploads/images/20260908/2026090817303974c831720.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303974c831720.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.868Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 83596, + "sha256": "e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30", + "objectKey": "uploads/images/20260908/20260908173112c4da43174.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112c4da43174.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.345Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 105099, + "sha256": "d83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505", + "objectKey": "uploads/images/20260908/2026090817311649e2a9550.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817311649e2a9550.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.177Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 91939, + "sha256": "c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4", + "objectKey": "uploads/images/20260908/20260908173116162c66104.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116162c66104.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.246Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 89468, + "sha256": "70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7", + "objectKey": "uploads/images/20260908/20260908173116a53f24003.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116a53f24003.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.403Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 93259, + "sha256": "4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a", + "objectKey": "uploads/images/20260908/202609081731162a2021285.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731162a2021285.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.366Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 92724, + "sha256": "25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2", + "objectKey": "uploads/images/20260908/20260908173116813c87413.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116813c87413.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.381Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 98885, + "sha256": "3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d", + "objectKey": "uploads/images/20260908/20260908173116bfec43397.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116bfec43397.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.490Z" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 104015, + "sha256": "9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61", + "objectKey": "uploads/images/20260908/20260908173116dbdc29125.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116dbdc29125.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.696Z" + }, + { + "sourcePath": "package-chapter-04/assets/scenes/gui-xiang-1978.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "objectKey": "uploads/images/20260908/2026090817303974c831720.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303974c831720.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.868Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 113102, + "sha256": "52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef", + "objectKey": "uploads/images/20260908/20260908173116c5c618420.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116c5c618420.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.545Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 156680, + "sha256": "897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb", + "objectKey": "uploads/images/20260908/2026090817311649ffe3535.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817311649ffe3535.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.548Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 147490, + "sha256": "76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22", + "objectKey": "uploads/images/20260908/20260908173116ecae73791.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116ecae73791.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.638Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 124380, + "sha256": "c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c", + "objectKey": "uploads/images/20260908/202609081731352d7e82556.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731352d7e82556.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.678Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 159817, + "sha256": "a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8", + "objectKey": "uploads/images/20260908/2026090817313559ab83276.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313559ab83276.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.681Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 146026, + "sha256": "63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca", + "objectKey": "uploads/images/20260908/2026090817313533e949160.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313533e949160.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.303Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 150951, + "sha256": "172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567", + "objectKey": "uploads/images/20260908/202609081731350aa1d5788.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731350aa1d5788.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.947Z" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190776, + "sha256": "b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f", + "objectKey": "uploads/images/20260908/202609081731357238e1677.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731357238e1677.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.955Z" + }, + { + "sourcePath": "package-chapter-05/assets/scenes/gui-xiang-1978.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "objectKey": "uploads/images/20260908/2026090817303974c831720.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303974c831720.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.868Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190405, + "sha256": "f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40", + "objectKey": "uploads/images/20260908/20260908173135b00ff3493.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173135b00ff3493.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:09.953Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 140303, + "sha256": "0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae", + "objectKey": "uploads/images/20260908/20260908173135595af4252.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173135595af4252.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.422Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 181320, + "sha256": "caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75", + "objectKey": "uploads/images/20260908/202609081731352d9fb1212.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731352d9fb1212.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.096Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 150118, + "sha256": "f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469", + "objectKey": "uploads/images/20260908/2026090817313638f109229.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313638f109229.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.090Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 154740, + "sha256": "408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d", + "objectKey": "uploads/images/20260908/2026090817313660b804375.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313660b804375.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.407Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 142954, + "sha256": "84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53", + "objectKey": "uploads/images/20260908/20260908173138a61767853.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138a61767853.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.404Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 152752, + "sha256": "c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b", + "objectKey": "uploads/images/20260908/20260908173138fa0762681.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138fa0762681.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.580Z" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 170210, + "sha256": "bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5", + "objectKey": "uploads/images/20260908/2026090817313839b279656.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313839b279656.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.603Z" + }, + { + "sourcePath": "package-chapter-06/assets/scenes/gui-xiang-1995.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 67246, + "sha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f", + "objectKey": "uploads/images/20260908/20260908173040de54b5498.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173040de54b5498.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.897Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 165311, + "sha256": "42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720", + "objectKey": "uploads/images/20260908/20260908173138dff5a5536.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138dff5a5536.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.546Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 173413, + "sha256": "81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2", + "objectKey": "uploads/images/20260908/20260908173138191298369.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138191298369.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.555Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 128377, + "sha256": "fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5", + "objectKey": "uploads/images/20260908/20260908173138ec8e94150.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138ec8e94150.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.699Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 147055, + "sha256": "8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3", + "objectKey": "uploads/images/20260908/20260908173138b2b761219.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138b2b761219.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.004Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157850, + "sha256": "5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4", + "objectKey": "uploads/images/20260908/20260908173138c9d001251.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138c9d001251.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.732Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 174837, + "sha256": "7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a", + "objectKey": "uploads/images/20260908/20260908173139f46863978.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173139f46863978.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.746Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 130849, + "sha256": "19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5", + "objectKey": "uploads/images/20260908/2026090817313967a307026.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313967a307026.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.996Z" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 161095, + "sha256": "f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e", + "objectKey": "uploads/images/20260908/202609081731411da1c2125.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731411da1c2125.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.043Z" + }, + { + "sourcePath": "package-chapter-07/assets/scenes/gui-xiang-1995.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 67246, + "sha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f", + "objectKey": "uploads/images/20260908/20260908173040de54b5498.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173040de54b5498.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.897Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157367, + "sha256": "b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc", + "objectKey": "uploads/images/20260908/20260908173141cf0c01964.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173141cf0c01964.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:10.925Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 191697, + "sha256": "4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562", + "objectKey": "uploads/images/20260908/20260908173142053ed9914.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142053ed9914.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.082Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 198741, + "sha256": "2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c", + "objectKey": "uploads/images/20260908/20260908173142ef5784670.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142ef5784670.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.170Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 178634, + "sha256": "737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935", + "objectKey": "uploads/images/20260908/2026090817314274cfe2605.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817314274cfe2605.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.183Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 180813, + "sha256": "41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618", + "objectKey": "uploads/images/20260908/202609081731420bc315230.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731420bc315230.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.494Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 163905, + "sha256": "fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111", + "objectKey": "uploads/images/20260908/20260908173142d16d12935.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142d16d12935.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.237Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 116785, + "sha256": "6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea", + "objectKey": "uploads/images/20260908/202609081731425a0819698.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731425a0819698.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.312Z" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 138725, + "sha256": "2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743", + "objectKey": "uploads/images/20260908/202609081731429844c1305.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731429844c1305.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.308Z" + }, + { + "sourcePath": "package-chapter-08/assets/scenes/gui-xiang-1998.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 62727, + "sha256": "d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14", + "objectKey": "uploads/images/20260908/2026090817303983bc35301.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303983bc35301.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.958Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 150476, + "sha256": "59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f", + "objectKey": "uploads/images/20260908/20260908173142346106887.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142346106887.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.490Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 179019, + "sha256": "0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d", + "objectKey": "uploads/images/20260908/20260908173145f82762147.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145f82762147.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.472Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 192720, + "sha256": "9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af", + "objectKey": "uploads/images/20260908/202609081731451a0a48902.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731451a0a48902.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.454Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 139199, + "sha256": "cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff", + "objectKey": "uploads/images/20260908/20260908173145e43cc7774.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145e43cc7774.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.586Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 164727, + "sha256": "5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a", + "objectKey": "uploads/images/20260908/20260908173145050809349.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145050809349.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.615Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 180695, + "sha256": "5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443", + "objectKey": "uploads/images/20260908/202609081731456267e0864.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731456267e0864.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.636Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 176826, + "sha256": "9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f", + "objectKey": "uploads/images/20260908/202609081731451e62d8082.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731451e62d8082.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.742Z" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 152317, + "sha256": "b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724", + "objectKey": "uploads/images/20260908/2026090817314563ce19085.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817314563ce19085.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.338Z" + }, + { + "sourcePath": "package-chapter-09/assets/scenes/gui-xiang-2001.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 59408, + "sha256": "9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8", + "objectKey": "uploads/images/20260908/20260908173039ad5bc2437.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039ad5bc2437.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.969Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 170147, + "sha256": "42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8", + "objectKey": "uploads/images/20260908/20260908173145234414957.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145234414957.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.773Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 201027, + "sha256": "30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55", + "objectKey": "uploads/images/20260908/2026090817314577b3d2877.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817314577b3d2877.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.788Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 145630, + "sha256": "23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9", + "objectKey": "uploads/images/20260908/20260908173145d06c49932.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145d06c49932.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:11.972Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 142107, + "sha256": "be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde", + "objectKey": "uploads/images/20260908/202609081732037cfc44506.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732037cfc44506.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.088Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 189031, + "sha256": "a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd", + "objectKey": "uploads/images/20260908/202609081732034eb7f0368.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732034eb7f0368.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.039Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 161904, + "sha256": "6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446", + "objectKey": "uploads/images/20260908/202609081732039e6701414.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732039e6701414.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.136Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157096, + "sha256": "13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99", + "objectKey": "uploads/images/20260908/2026090817320373f998007.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320373f998007.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.202Z" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 205032, + "sha256": "2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0", + "objectKey": "uploads/images/20260908/20260908173203dace72023.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173203dace72023.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.295Z" + }, + { + "sourcePath": "package-chapter-10/assets/scenes/gui-xiang-2003.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 53822, + "sha256": "68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431", + "objectKey": "uploads/images/20260908/2026090817303985ee07761.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303985ee07761.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:04.986Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 170560, + "sha256": "840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a", + "objectKey": "uploads/images/20260908/202609081732035f2e17452.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732035f2e17452.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.497Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 122506, + "sha256": "112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1", + "objectKey": "uploads/images/20260908/20260908173203e27952820.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173203e27952820.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.402Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 95514, + "sha256": "23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51", + "objectKey": "uploads/images/20260908/202609081732032db544840.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732032db544840.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.495Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 185485, + "sha256": "3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50", + "objectKey": "uploads/images/20260908/202609081732035c9d61064.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732035c9d61064.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.499Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 120040, + "sha256": "c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5", + "objectKey": "uploads/images/20260908/202609081732037c8583818.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732037c8583818.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.719Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 172410, + "sha256": "4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c", + "objectKey": "uploads/images/20260908/2026090817320602c476049.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320602c476049.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.612Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 157302, + "sha256": "9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a", + "objectKey": "uploads/images/20260908/20260908173206dae776115.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206dae776115.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.648Z" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 112557, + "sha256": "b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de", + "objectKey": "uploads/images/20260908/202609081732065676e2434.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732065676e2434.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.761Z" + }, + { + "sourcePath": "package-chapter-11/assets/scenes/gui-xiang-2008.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 53286, + "sha256": "c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5", + "objectKey": "uploads/images/20260908/2026090817303994b4b1401.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303994b4b1401.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.021Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 167948, + "sha256": "e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a", + "objectKey": "uploads/images/20260908/20260908173206a7ebe7583.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206a7ebe7583.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.828Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190911, + "sha256": "6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602", + "objectKey": "uploads/images/20260908/20260908173206cb3721071.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206cb3721071.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.803Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 133362, + "sha256": "c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9", + "objectKey": "uploads/images/20260908/20260908173206b42891414.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206b42891414.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.849Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 152941, + "sha256": "1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e", + "objectKey": "uploads/images/20260908/2026090817320615d6c3156.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320615d6c3156.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:12.899Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 164550, + "sha256": "695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0", + "objectKey": "uploads/images/20260908/20260908173206946d11690.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206946d11690.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.145Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 153998, + "sha256": "9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76", + "objectKey": "uploads/images/20260908/202609081732067211a5043.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732067211a5043.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.018Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 195056, + "sha256": "338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5", + "objectKey": "uploads/images/20260908/2026090817320628b5f1545.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320628b5f1545.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.076Z" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 135599, + "sha256": "a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f", + "objectKey": "uploads/images/20260908/20260908173210f33956967.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210f33956967.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.067Z" + }, + { + "sourcePath": "package-chapter-12/assets/scenes/gui-xiang-2026.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "objectKey": "uploads/images/20260908/20260908173039628910741.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039628910741.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.155Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 191222, + "sha256": "2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e", + "objectKey": "uploads/images/20260908/20260908173210dcc904301.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210dcc904301.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.154Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 201951, + "sha256": "a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198", + "objectKey": "uploads/images/20260908/202609081732104dda17591.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732104dda17591.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.213Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 201320, + "sha256": "13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b", + "objectKey": "uploads/images/20260908/202609081732103e1737129.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732103e1737129.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.231Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 173154, + "sha256": "d4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d", + "objectKey": "uploads/images/20260908/20260908173210052871552.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210052871552.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.336Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 214867, + "sha256": "2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff", + "objectKey": "uploads/images/20260908/202609081732106acce6348.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732106acce6348.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.431Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 184565, + "sha256": "8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d", + "objectKey": "uploads/images/20260908/2026090817321063e243356.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321063e243356.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.352Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 198924, + "sha256": "edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b", + "objectKey": "uploads/images/20260908/202609081732103c91a8938.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732103c91a8938.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.382Z" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 179621, + "sha256": "f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc", + "objectKey": "uploads/images/20260908/20260908173210e3e400579.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210e3e400579.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.604Z" + }, + { + "sourcePath": "package-chapter-13/assets/scenes/gui-xiang-2026.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "objectKey": "uploads/images/20260908/20260908173039628910741.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039628910741.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.155Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 173212, + "sha256": "6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2", + "objectKey": "uploads/images/20260908/20260908173210a1bfa8588.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210a1bfa8588.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.616Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 204940, + "sha256": "15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48", + "objectKey": "uploads/images/20260908/2026090817321402ddc8471.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321402ddc8471.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.556Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 181140, + "sha256": "9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365", + "objectKey": "uploads/images/20260908/20260908173214a4a8d4074.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214a4a8d4074.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.669Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 190877, + "sha256": "899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e", + "objectKey": "uploads/images/20260908/202609081732148dba50220.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732148dba50220.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.808Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 181882, + "sha256": "b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801", + "objectKey": "uploads/images/20260908/202609081732146ee292192.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732146ee292192.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.778Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 192247, + "sha256": "c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd", + "objectKey": "uploads/images/20260908/2026090817321455aa99619.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321455aa99619.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.792Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 166223, + "sha256": "d0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d", + "objectKey": "uploads/images/20260908/20260908173214765e24253.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214765e24253.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.904Z" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 172139, + "sha256": "09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c", + "objectKey": "uploads/images/20260908/20260908173214ac7b62068.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214ac7b62068.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:14.064Z" + }, + { + "sourcePath": "package-chapter-14/assets/scenes/gui-xiang-2026.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "objectKey": "uploads/images/20260908/20260908173039628910741.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039628910741.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.155Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 207370, + "sha256": "194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab", + "objectKey": "uploads/images/20260908/202609081732143d4070553.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732143d4070553.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.941Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 189454, + "sha256": "b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b", + "objectKey": "uploads/images/20260908/2026090817321405dc63371.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321405dc63371.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:13.969Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 162103, + "sha256": "b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173", + "objectKey": "uploads/images/20260908/20260908173214896801232.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214896801232.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:14.197Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 138672, + "sha256": "2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea", + "objectKey": "uploads/images/20260908/20260908173218201a61275.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173218201a61275.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:14.169Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 166021, + "sha256": "21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a", + "objectKey": "uploads/images/20260908/20260908173218191279663.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173218191279663.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:14.279Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 172654, + "sha256": "3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50", + "objectKey": "uploads/images/20260908/202609081732189a41f9598.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732189a41f9598.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:14.274Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 210429, + "sha256": "98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98", + "objectKey": "uploads/images/20260908/202609081732189ef359918.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732189ef359918.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:14.438Z" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 96215, + "sha256": "520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113", + "objectKey": "uploads/images/20260908/20260908173218765170829.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173218765170829.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:14.435Z" + }, + { + "sourcePath": "package-chapter-15/assets/scenes/gui-xiang-2026.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "objectKey": "uploads/images/20260908/20260908173039628910741.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039628910741.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.155Z" + }, + { + "sourcePath": "package-game/assets/audio/S01-C01-MS007.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 18957, + "sha256": "1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8", + "objectKey": "uploads/voice/20260908/1788859755676-8bbjnyhb.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859755676-8bbjnyhb.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:14.653Z" + }, + { + "sourcePath": "package-game/assets/audio/S01-C01-MS010.mp3", + "kind": "audio", + "contentType": "audio/mpeg", + "bytes": 17421, + "sha256": "628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597", + "objectKey": "uploads/voice/20260908/1788859756823-r37bn776.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859756823-r37bn776.mp3", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597", + "rangeVerified": true, + "verifiedAt": "2026-09-08T09:36:14.671Z" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 105278, + "sha256": "12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a", + "objectKey": "uploads/images/20260908/20260908173039668f11872.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039668f11872.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.577Z" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 91819, + "sha256": "6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80", + "objectKey": "uploads/images/20260908/202609081730407a8a27957.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081730407a8a27957.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.482Z" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 120832, + "sha256": "bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce", + "objectKey": "uploads/images/20260908/202609081730409483f1003.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081730409483f1003.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.546Z" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 111834, + "sha256": "d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f", + "objectKey": "uploads/images/20260908/2026090817310719bb62047.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817310719bb62047.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.911Z" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 120294, + "sha256": "7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750", + "objectKey": "uploads/images/20260908/202609081731073a6bf7270.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731073a6bf7270.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.975Z" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 96418, + "sha256": "4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31", + "objectKey": "uploads/images/20260908/20260908173107b3c447822.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173107b3c447822.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.964Z" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 99232, + "sha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69", + "objectKey": "uploads/images/20260908/202609081718480ee488780.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081718480ee488780.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.157Z" + }, + { + "sourcePath": "package-game/assets/scenes/gui-xiang-2026.jpg", + "kind": "image", + "contentType": "image/jpeg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "objectKey": "uploads/images/20260908/20260908173039628910741.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039628910741.jpg", + "uploaded": true, + "publicReadVerified": true, + "remoteVerifiedSha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "rangeVerified": null, + "verifiedAt": "2026-09-08T09:36:05.155Z" + } + ] +} diff --git a/TongjiUniApp/build/tang-detective-cos-media.mjs b/TongjiUniApp/build/tang-detective-cos-media.mjs new file mode 100644 index 0000000..4f08fc1 --- /dev/null +++ b/TongjiUniApp/build/tang-detective-cos-media.mjs @@ -0,0 +1,251 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import vm from 'node:vm' +import { fileURLToPath } from 'node:url' +import { MEDIA_TYPES as TYPES, isMediaFile, readSourceManifest, validateSourceSnapshot, SOURCE_MANIFEST_PATH } from './tang-detective-source-validation.mjs' +export { isMediaFile, SOURCE_MANIFEST_PATH } from './tang-detective-source-validation.mjs' + +const buildDirectory = path.dirname(fileURLToPath(import.meta.url)) +export const DEFAULT_MEDIA_MANIFEST_PATH = path.join(buildDirectory, 'tang-detective-cos-manifest.json') +const RUNTIME_HELPER_PATH = path.resolve(buildDirectory, '../native-adapter/tang-detective/utils/cosMedia.js') +const SHA256 = /^[a-f0-9]{64}$/ +const SERVER_DESTINATION = { bucket: 'gz-1349751149', region: 'ap-guangzhou', + baseUrl: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com' } +const hash = value => crypto.createHash('sha256').update(value).digest('hex') +const json = value => `${JSON.stringify(value, null, 2)}\n` + +function assert(condition, message) { + if (!condition) throw new Error(`Invalid Tang Detective COS manifest: ${message}`) +} + +function safeRelative(value) { + return typeof value === 'string' && value.length > 0 && !value.startsWith('/') + && !/[\\?#\u0000-\u0020]/.test(value) + && value.split('/').every(part => part && part !== '.' && part !== '..') +} + +function httpsUrl(value) { + try { + assert(typeof value === 'string' && !/["'`<>\\\s]/.test(value), 'URL contains unsafe literal characters') + const parsed = new URL(value) + assert(parsed.protocol === 'https:' && !parsed.username && !parsed.password + && !parsed.search && !parsed.hash, 'URLs must use unsigned HTTPS') + return parsed + } catch (error) { + throw new Error(`Invalid Tang Detective COS manifest: invalid HTTPS URL (${error.message})`) + } +} + +function validUploadDate(value) { + if (!/^20\d{6}$/.test(value)) return false + const isoDate = `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}` + const parsed = new Date(`${isoDate}T00:00:00.000Z`) + return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === isoDate +} + +function confirmedServerObject(entry, kind) { + if (kind === 'image' && path.extname(entry.sourcePath) === '.jpg') { + // UploadService::image and storage/engine/Server::buildSaveName. + const match = /^uploads\/images\/(20\d{6})\/(20\d{6})([0-2]\d)([0-5]\d)([0-5]\d)[a-f0-9]{5}\d{4}\.jpg$/.exec(entry.objectKey) + return Boolean(match && validUploadDate(match[1]) && match[1] === match[2] && Number(match[3]) < 24) + } + if (kind === 'audio' && path.extname(entry.sourcePath) === '.mp3') { + // Actual authenticated voice upload, using the admin direct-uploader's + // Date.now() + Math.random().toString(36).slice(2, 10) naming. + const match = /^uploads\/voice\/(20\d{6})\/([1-9]\d{12})-[a-z0-9]{8}\.mp3$/.exec(entry.objectKey) + return Boolean(match && validUploadDate(match[1])) + } + return false +} + +export function validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath = SOURCE_MANIFEST_PATH, + pruneReceipt, pruneReceiptPath } = {}) { + assert(mediaManifest && [1, 2].includes(mediaManifest.schemaVersion), 'schemaVersion must be 1 or 2') + const serverManaged = mediaManifest.schemaVersion === 2 + if (serverManaged) { + assert(typeof mediaManifest.uploadRunId === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(mediaManifest.uploadRunId), 'uploadRunId is required') + } + const source = readSourceManifest(sourceManifestPath) + assert(mediaManifest.sourceManifestSha256 === source.manifestSha256, 'source manifest SHA-256 mismatch') + const sourceManifest = source.manifest + const expected = new Map(sourceManifest.files.filter(file => isMediaFile(file.path)).map(file => [file.path, file])) + const destination = mediaManifest.destination + assert(destination && typeof destination.bucket === 'string' && destination.bucket.trim() + && typeof destination.region === 'string' && destination.region.trim(), 'destination bucket and region are required') + const base = httpsUrl(destination.baseUrl) + if (serverManaged) { + assert(Object.entries(SERVER_DESTINATION).every(([key, value]) => destination[key] === value), + 'schema 2 destination must be the confirmed xuetang COS bucket, region and origin') + } + assert(Array.isArray(mediaManifest.entries) && mediaManifest.entries.length === expected.size, 'media coverage is incomplete') + const entries = new Map() + const objects = new Map() + for (const entry of mediaManifest.entries) { + assert(entry && safeRelative(entry.sourcePath) && expected.has(entry.sourcePath), 'unknown or unsafe sourcePath') + assert(!entries.has(entry.sourcePath), `duplicate sourcePath: ${entry.sourcePath}`) + const recorded = expected.get(entry.sourcePath) + const [kind, contentType] = TYPES[path.extname(entry.sourcePath).toLowerCase()] + assert(entry.kind === kind && entry.contentType === contentType, `media type mismatch: ${entry.sourcePath}`) + assert(SHA256.test(entry.sha256) && entry.sha256 === recorded.sha256 + && Number.isSafeInteger(entry.bytes) && entry.bytes === recorded.bytes, `source metadata mismatch: ${entry.sourcePath}`) + assert(entry.uploaded === true && entry.remoteVerifiedSha256 === entry.sha256, `remote verification missing: ${entry.sourcePath}`) + assert(entry.publicReadVerified === true, `public read verification missing: ${entry.sourcePath}`) + if (serverManaged) { + // Only these two media routes have current upload evidence. New kinds + // require their own verified route; schema 1 stays content-addressed. + assert(confirmedServerObject(entry, kind), + `object key does not match confirmed server upload contract: ${entry.sourcePath}`) + } else { + const expectedObjectKey = `tang-detective/season-01/media-v1/${entry.sha256}${path.extname(entry.sourcePath)}` + assert(entry.objectKey === expectedObjectKey, `object key does not match immutable media contract: ${entry.sourcePath}`) + } + if (kind === 'audio' || kind === 'video') { + assert(entry.rangeVerified === true, `media range verification missing: ${entry.sourcePath}`) + } + const url = httpsUrl(entry.url) + const expectedUrl = `${base.href.replace(/\/$/, '')}/${entry.objectKey.split('/').map(encodeURIComponent).join('/')}` + assert(url.href === expectedUrl && entry.url === url.href, `URL does not match destination and object key: ${entry.sourcePath}`) + const identity = `${entry.sha256}:${entry.bytes}:${entry.contentType}` + assert(!objects.has(entry.url) || objects.get(entry.url) === identity, `conflicting object contents: ${entry.sourcePath}`) + objects.set(entry.url, identity) + entries.set(entry.sourcePath, Object.freeze({ ...entry })) + } + const media = { entries, sourceManifestSha256: mediaManifest.sourceManifestSha256, + manifestSha256: hash(json(mediaManifest)), destination: { ...destination }, objectCount: objects.size, + uploadRunId: mediaManifest.uploadRunId } + // Validate all surviving bytes, and permit omissions only after validating a + // complete local prune receipt bound to this exact verified upload manifest. + media.sourceSnapshot = validateSourceSnapshot({ sourceDirectory, sourceManifestPath, media, pruneReceipt, pruneReceiptPath }) + return media +} + +export function loadCosMediaManifest({ mediaManifest, mediaManifestPath = DEFAULT_MEDIA_MANIFEST_PATH, sourceDirectory, sourceManifestPath, + pruneReceipt, pruneReceiptPath } = {}) { + // Explicit null is useful for a local/offline comparison without touching a + // verified manifest owned by another task. Undefined uses automatic discovery. + if (mediaManifest === null) return null + if (mediaManifest === undefined) { + if (!fs.existsSync(mediaManifestPath)) return null + const stat = fs.lstatSync(mediaManifestPath) + assert(stat.isFile() && !stat.isSymbolicLink(), 'media manifest must be a regular file') + mediaManifest = JSON.parse(fs.readFileSync(mediaManifestPath, 'utf8')) + } + return validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath, pruneReceipt, pruneReceiptPath }) +} + +function relativeHelper(relative, helper = 'utils/cosMedia.js') { + const result = path.posix.relative(path.posix.dirname(relative), helper) + return result.startsWith('.') ? result : `./${result}` +} + +function replaceOnce(source, before, after, filename) { + assert(source.split(before).length === 2, `conversion anchor changed: ${filename}`) + return source.replace(before, after) +} + +function evaluateData(source, filename) { + const context = { module: { exports: {} } } + vm.runInNewContext(source, context, { filename, timeout: 1000 }) + return context.module.exports +} + +export function applyCosMediaOutput(files, media, { namespace = 'tang-detective' } = {}) { + if (!media) return + const byUrl = new Map([...media.entries.values()].map(entry => [entry.url, entry])) + const lookup = value => { + if (byUrl.has(value)) return byUrl.get(value) + let relative = value.replace(/^\//, '') + if (relative.startsWith(`${namespace}/`)) relative = relative.slice(namespace.length + 1) + return media.entries.get(relative) + } + const staticPath = /(["'`])(\/(?:[a-z0-9-]+\/)?(?:assets|package-[a-z0-9-]+)\/[^"'`\n$]*\.(?:jpe?g|png|webp|gif|svg|avif|mp3|wav|aac|m4a|ogg|mp4|webm|mov))\1/gi + for (const [relative, bytes] of files) { + if (isMediaFile(relative)) { files.delete(relative); continue } + if (!/\.(?:js|json|wxml|wxss|wxs)$/.test(relative) || relative === 'utils/cosMedia.js') continue + let source = bytes.toString('utf8') + const helper = `require(${JSON.stringify(relativeHelper(relative))})` + if (relative.endsWith('/data/releaseAssetManifest.js')) { + const exported = evaluateData(source, relative) + for (const asset of Object.values(exported.releaseAssets)) { + if (!asset.localSeed) continue + const entry = lookup(asset.localSeed) + assert(entry && entry.sha256 === asset.sha256 && entry.kind === asset.kind, `release asset mismatch: ${relative}`) + asset.localSeed = '' + asset.remoteUrl = entry.url + } + const share = media.entries.get('assets/share/guixiang-story-share-preview-v1.jpg') + assert(share, 'share preview entry is missing') + exported.releaseAssets['image.tang.share-preview'] = { + kind: 'image', localSeed: '', remoteUrl: share.url, + remotePath: share.objectKey, sha256: share.sha256, + } + files.set(relative, Buffer.from(`module.exports = ${json(exported)}`)) + continue + } + source = source.replace(staticPath, (match, quote, value) => { + const entry = lookup(value) + assert(entry, `unregistered static media: ${relative}: ${value}`) + return `${quote}${entry.url}${quote}` + }) + if (relative.endsWith('/pages/chapter/chapterPages.js')) { + source = replaceOnce(source, ' pageSequence = attachPlayableVisuals(pageSequence, chapter)', + ` pageSequence = ${helper}.mapMedia(pageSequence)\n pageSequence = attachPlayableVisuals(pageSequence, chapter)`, relative) + } + if (relative.endsWith('/data/playableVisualPolicy.js')) { + const previous = ' && clean(releaseAsset.localSeed)\n && clean(releaseAsset.localSeed) === clean(page.illustrationAsset),' + source = replaceOnce(source, previous, + ` && ${helper}.verifiedUrl(releaseAsset.remoteUrl, releaseAsset.sha256, 'image')\n && clean(releaseAsset.remoteUrl) === clean(page.illustrationAsset),`, relative) + } + if (relative.endsWith('/utils/comicPageModel.js')) { + source = replaceOnce(source, ' if (localSeed) {\n return {\n src: localSeed,', + ` const remoteUrl = releaseAsset && ${helper}.verifiedUrl(\n releaseAsset.remoteUrl, releaseAsset.sha256, 'image',\n )\n if (localSeed || remoteUrl) {\n return {\n src: localSeed || remoteUrl,`, relative) + source = replaceOnce(source, " source: 'local-seed',", " source: localSeed ? 'local-seed' : 'remote-url',", relative) + // Keep getReviewedAudioSrc and isPackagedPath fail-closed. Approved + // remote full-page audio uses the existing asynchronous verified player. + } + if (relative.endsWith('/utils/assetManager.js')) { + source = replaceOnce(source, ' if (!cdnBaseUrl || !asset.remotePath) {', + ` const remoteUrl = asset.remoteUrl\n ? ${helper}.verifiedUrl(asset.remoteUrl, asset.sha256, asset.kind) : ''\n if (asset.remoteUrl && !remoteUrl) return fallback(assetId, 'remote-integrity-failed')\n if (!remoteUrl && (!cdnBaseUrl || !asset.remotePath)) {`, relative) + source = replaceOnce(source, 'download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath))', + 'download(assetPlatform, remoteUrl || joinPath(cdnBaseUrl, asset.remotePath))', relative) + } + if (relative.endsWith('/pages/chapter/chapter.js')) { + const start = source.indexOf(' prepareSharePreview() {') + const end = source.indexOf(' loadChapter(', start) + assert(start !== -1 && end > start, `share conversion anchor changed: ${relative}`) + source = source.slice(0, start) + + ` prepareSharePreview() {\n return ${helper}.prepareSharePreview(this, this.getAssetManager())\n },\n\n` + + source.slice(end) + source = replaceOnce(source, " const localPath = String(this.data.sharePreviewLocalPath || '').trim()", + " const localPath = '' // Cached share bytes are revalidated in prepareSharePreview.", relative) + source = replaceOnce(source, ' if (!pageData.currentPage) return', + ` if (!pageData.currentPage) return\n ${helper}.beginComicImage(this, pageData)`, relative) + source = replaceOnce(source, ' onComicImageLoad() {', + ` onComicImageLoad(event) {\n if (!${helper}.isCurrentComicImageEvent(this, event)) return`, relative) + const errorStart = source.indexOf(' onComicImageError() {') + const errorEnd = source.indexOf(' applyLayoutMetrics(', errorStart) + assert(errorStart !== -1 && errorEnd > errorStart, `image error conversion anchor changed: ${relative}`) + let handler = source.slice(errorStart, errorEnd) + handler = replaceOnce(handler, ' onComicImageError() {', + ` onComicImageError(event) {\n if (!${helper}.recordComicImageError(this, event)) return`, relative) + handler = replaceOnce(handler, ' actorFallback\n', + ` actorFallback\n && ${helper}.canUseComicFallback(this, actorFallback)\n`, relative) + handler = replaceOnce(handler, ' fallback\n', + ` fallback\n && ${helper}.canUseComicFallback(this, fallback)\n`, relative) + handler = handler.replaceAll('this.setData({', `${helper}.applyComicImageFallback(this, {`) + source = source.slice(0, errorStart) + handler + source.slice(errorEnd) + } + if (relative.endsWith('/pages/chapter/chapter.wxml')) { + assert(source.includes('binderror="onComicImageError"'), `image event conversion anchor changed: ${relative}`) + source = source.replaceAll('binderror="onComicImageError"', + 'data-cos-page-id="{{currentPageId}}" data-cos-image-src="{{comicImageSrc}}" data-cos-image-generation="{{comicImageGeneration}}" binderror="onComicImageError"') + } + files.set(relative, Buffer.from(source)) + } + files.set('utils/cosMedia.js', fs.readFileSync(RUNTIME_HELPER_PATH)) + files.set('utils/cosMediaManifest.js', Buffer.from(`module.exports = ${json({ + namespace, + entries: [...media.entries.values()].map(({ sourcePath, kind, bytes, sha256, url }) => ({ sourcePath, kind, bytes, sha256, url })), + })}`)) +} diff --git a/TongjiUniApp/build/tang-detective-cos-media.test.mjs b/TongjiUniApp/build/tang-detective-cos-media.test.mjs new file mode 100644 index 0000000..83d563c --- /dev/null +++ b/TongjiUniApp/build/tang-detective-cos-media.test.mjs @@ -0,0 +1,559 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import vm from 'node:vm' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { copyNativeProgram, listFiles, sha256 } from './tang-detective-native-plugin.mjs' +import { isMediaFile, loadCosMediaManifest, validateCosMediaManifest, SOURCE_MANIFEST_PATH } from './tang-detective-cos-media.mjs' +import { validateNativeOutput } from './validate-tang-detective-output.mjs' +import { createSyntheticSource, fakeVerifiedManifest, fakePruneReceipt } from './tang-detective-test-fixtures.mjs' + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const sourceDirectory = path.join(projectRoot, 'native/tang-detective') +const overlayDirectory = path.join(projectRoot, 'native-adapter/tang-detective') +const sourceBytes = fs.readFileSync(SOURCE_MANIFEST_PATH) +const sourceManifest = JSON.parse(sourceBytes) +const sourceRequire = createRequire(path.join(projectRoot, 'source-cos-test.cjs')) +const sourceMedia = sourceManifest.files.filter(file => isMediaFile(file.path)) + +function write(directory, relative, value) { + const filename = path.join(directory, relative) + fs.mkdirSync(path.dirname(filename), { recursive: true }) + fs.writeFileSync(filename, value) +} + +function fixture(t, { remote = true } = {}) { + const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tang-cos-unit-'))) + // Every path removed below was created exclusively by this test. + t.after(() => fs.rmSync(temporary, { recursive: true, force: true })) + const outputDirectory = path.join(temporary, 'output') + const source = createSyntheticSource(temporary) + write(outputDirectory, 'app.json', JSON.stringify({ pages: ['pages/index/index'], window: { navigationStyle: 'default' } })) + write(outputDirectory, 'app.js', '/* retained host entry */') + const mediaManifest = remote ? fakeVerifiedManifest(source) : null + const options = { sourceDirectory: source.sourceDirectory, sourceManifestPath: source.sourceManifestPath, + overlayDirectory, outputDirectory, mediaManifest } + const report = copyNativeProgram(options) + const root = path.join(outputDirectory, 'tang-detective') + const localRequire = createRequire(path.join(outputDirectory, 'test.cjs')) + return { ...source, temporary, root, options, report, outputDirectory, mediaManifest, + require: relative => localRequire(path.join(root, relative)) } +} + +function mockPlatform(responses) { + const files = new Map() + const downloads = [] + let sequence = 0 + let failDownloads = false + const buffer = value => typeof value === 'string' ? Buffer.from(value) : Buffer.from(value) + const fsApi = { + mkdir: options => options.success({}), + access: options => files.has(options.path) ? options.success({}) : options.fail(new Error('missing')), + readFile(options) { + if (!files.has(options.filePath)) { options.fail(new Error('missing')); return } + const data = files.get(options.filePath) + options.success({ data: options.encoding === 'utf8' ? buffer(data).toString('utf8') : buffer(data) }) + }, + writeFile(options) { files.set(options.filePath, buffer(options.data)); options.success({}) }, + saveFile(options) { + if (!files.has(options.tempFilePath)) { options.fail(new Error('missing')); return } + files.set(options.filePath, files.get(options.tempFilePath)) + files.delete(options.tempFilePath) + options.success({ savedFilePath: options.filePath }) + }, + stat(options) { options.success({ stats: { size: buffer(files.get(options.path)).length } }) }, + unlink(options) { files.delete(options.filePath); options.success({}) }, + } + const platform = { + env: { USER_DATA_PATH: '/test-owned-user-data' }, + getFileSystemManager: () => fsApi, + downloadFile(options) { + downloads.push(options.url) + if (failDownloads || !responses.has(options.url)) { options.fail(new Error('network unavailable')); return } + const body = responses.get(options.url) + const tempFilePath = `/test-owned-temp/${++sequence}` + files.set(tempFilePath, Buffer.from(body)) + options.success({ statusCode: 200, tempFilePath, fileSize: body.length }) + }, + } + return { platform, files, downloads, failDownloads: value => { failDownloads = value } } +} + +function managerFixture(context) { + const { releaseAssets } = context.require('package-game/data/releaseAssetManifest.js') + const managerApi = context.require('package-game/utils/assetManager.js') + const responses = new Map(context.mediaManifest.entries.map(entry => [entry.url, fs.readFileSync(path.join(context.sourceDirectory, entry.sourcePath))])) + const mock = mockPlatform(responses) + const manager = managerApi.createAssetManager({ manifest: releaseAssets, assetPlatform: mock.platform, cdnBaseUrl: '' }) + return { ...mock, manager, managerApi, releaseAssets, responses } +} + +test('verified manifest requires all 204 original paths, hashes, exact HTTPS objects and upload receipts', t => { + const context = fixture(t) + const media = validateCosMediaManifest(fakeVerifiedManifest(context), context.options) + assert.equal(media.entries.size, 204) + assert.equal(media.objectCount, 180) + const mutations = [ + manifest => { manifest.entries.pop() }, + manifest => { manifest.entries[1] = { ...manifest.entries[0] } }, + manifest => { manifest.sourceManifestSha256 = '0'.repeat(64) }, + manifest => { manifest.entries[0].sha256 = '0'.repeat(64) }, + manifest => { manifest.entries[0].bytes += 1 }, + manifest => { manifest.entries[0].remoteVerifiedSha256 = '0'.repeat(64) }, + manifest => { manifest.entries[0].uploaded = false }, + manifest => { manifest.entries[0].publicReadVerified = false }, + manifest => { manifest.entries[0].url = manifest.entries[0].url.replace('https:', 'http:') }, + manifest => { manifest.entries[0].url += '?signature=not-allowed' }, + manifest => { manifest.entries[0].url = 'https://other.example.test/file.jpg' }, + manifest => { manifest.destination.baseUrl = "https://tang-assets.example.test/quote'" }, + manifest => { manifest.entries[0].sourcePath = '../outside.jpg' }, + manifest => { manifest.entries[0].objectKey = 'mutable-name.jpg' }, + manifest => { + const entry = manifest.entries[0] + entry.objectKey = `wrong-prefix/${entry.sha256}${path.extname(entry.sourcePath)}` + entry.url = `${manifest.destination.baseUrl}/${entry.objectKey}` + }, + manifest => { + const entry = manifest.entries[0] + entry.objectKey = `tang-detective/season-01/media-v1/${entry.sha256.slice(0, 12)}${path.extname(entry.sourcePath)}` + entry.url = `${manifest.destination.baseUrl}/${entry.objectKey}` + }, + manifest => { delete manifest.entries.find(entry => entry.kind === 'audio').rangeVerified }, + manifest => { manifest.entries.find(entry => entry.kind === 'audio').rangeVerified = false }, + manifest => { manifest.entries[0].contentType = 'text/html' }, + ] + for (const mutate of mutations) { + const manifest = fakeVerifiedManifest(context) + mutate(manifest) + assert.throws(() => validateCosMediaManifest(manifest, context.options), /Invalid Tang Detective COS manifest/) + } +}) + +test('schema 2 accepts only the confirmed COS origin and exact server image / voice object formats', t => { + const context = fixture(t) + const baseUrl = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com' + const manifest = { ...context.mediaManifest, schemaVersion: 2, + destination: { bucket: 'gz-1349751149', region: 'ap-guangzhou', baseUrl }, + entries: context.mediaManifest.entries.map((entry, index) => { + const objectKey = entry.kind === 'image' + ? `uploads/images/20260908/20260908171848abcde${String(index).padStart(4, '0')}.jpg` + : `uploads/voice/20260908/1788859559652-${String(index).padStart(8, '0')}.mp3` + return { ...entry, objectKey, url: `${baseUrl}/${objectKey}` } + }), + } + assert.equal(validateCosMediaManifest(manifest, context.options).entries.size, 204) + const mutations = [ + value => { value.uploadRunId = '' }, + value => { value.destination.bucket = 'other-bucket' }, + value => { value.destination.region = 'ap-shanghai' }, + value => { value.destination.baseUrl = 'https://different.example.test' }, + value => { value.destination.baseUrl += '/uploads' }, + value => { value.entries[0].objectKey = 'uploads/images/20260908/202609081718480ee488780.jpg?signature=x' }, + value => { value.entries[0].objectKey = 'uploads/images/20260909/202609081718480ee488780.jpg' }, + value => { value.entries[0].objectKey = 'uploads/images/20260230/202602301718480ee488780.jpg' }, + value => { value.entries[0].objectKey = 'uploads/images/20260908/202609082918480ee488780.jpg' }, + value => { value.entries[0].objectKey = 'uploads/images/20260908/mutable.jpg' }, + value => { value.entries[0].objectKey = 'uploads/file/20260908/202609081718480ee488780.jpg' }, + value => { value.entries[0].url = 'https://other.example.test/202609081718480ee488780.jpg' }, + value => { value.entries[0].uploaded = false }, + value => { value.entries[0].publicReadVerified = false }, + value => { value.entries[0].remoteVerifiedSha256 = '0'.repeat(64) }, + value => { value.entries.find(entry => entry.kind === 'audio').rangeVerified = false }, + value => { value.entries.find(entry => entry.kind === 'audio').objectKey = 'uploads/images/20260908/1788859559652-e90yvvdk.mp3' }, + value => { value.entries.find(entry => entry.kind === 'audio').objectKey = 'uploads/voice/20260908/1788859559652-e90yvvdk.mp4' }, + value => { value.entries.find(entry => entry.kind === 'audio').objectKey = 'uploads/voice/20260908/1788859559652-unsafe_!.mp3' }, + ] + for (const mutate of mutations) { + const changed = structuredClone(manifest) + mutate(changed) + assert.throws(() => validateCosMediaManifest(changed, context.options), /Invalid Tang Detective COS manifest/) + } + // Confirm schema 1 is still unable to accept any server-managed object. + assert.throws(() => validateCosMediaManifest({ ...manifest, schemaVersion: 1 }, context.options), /immutable media contract/) + for (const entry of context.sourceMedia) fs.unlinkSync(path.join(context.sourceDirectory, entry.path)) + const options = { ...context.options, mediaManifest: manifest, pruneReceipt: fakePruneReceipt(manifest) } + assert.equal(copyNativeProgram(options).source.omittedMediaFiles, 204) + assert.deepEqual(validateNativeOutput(context.outputDirectory, options).errors, []) +}) + +test('video manifest also requires a true range receipt and its exact immutable object key', t => { + const context = fixture(t) + const source = path.join(context.temporary, 'video-source') + const sourcePath = 'assets/video/test.mp4' + const bytes = Buffer.from('offline transport contract fixture') + write(source, sourcePath, bytes) + const record = { path: sourcePath, bytes: bytes.length, sha256: sha256(bytes) } + const smallSource = JSON.stringify({ files: [record] }) + write(context.temporary, 'video-source-manifest.json', smallSource) + const sourceManifestPath = path.join(context.temporary, 'video-source-manifest.json') + const objectKey = `tang-detective/season-01/media-v1/${record.sha256}.mp4` + const manifest = { ...context.mediaManifest, sourceManifestSha256: sha256(smallSource), entries: [{ + sourcePath, kind: 'video', contentType: 'video/mp4', bytes: record.bytes, sha256: record.sha256, + objectKey, url: `${context.mediaManifest.destination.baseUrl}/${objectKey}`, + uploaded: true, publicReadVerified: true, remoteVerifiedSha256: record.sha256, rangeVerified: true, + }] } + assert.equal(validateCosMediaManifest(manifest, { sourceDirectory: source, sourceManifestPath }).entries.size, 1) + for (const value of [undefined, false, 'true']) { + manifest.entries[0].rangeVerified = value + assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory: source, sourceManifestPath }), /range verification missing/) + } +}) + +test('source byte drift fails even when a manifest still claims the expected hash', t => { + const context = fixture(t) + const source = path.join(context.temporary, 'changed-source') + const original = context.mediaManifest.entries[0] + write(source, original.sourcePath, 'changed source bytes') + const smallSource = JSON.stringify({ files: [{ path: original.sourcePath, bytes: original.bytes, sha256: original.sha256 }] }) + write(context.temporary, 'small-source-manifest.json', smallSource) + const manifest = { ...context.mediaManifest, sourceManifestSha256: sha256(smallSource), entries: [original] } + assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory: source, + sourceManifestPath: path.join(context.temporary, 'small-source-manifest.json') }), /source bytes changed/) +}) + +test('missing manifest stays local; a verified remote import removes only owned output media and remains deterministic', t => { + const context = fixture(t, { remote: false }) + const missing = path.join(context.temporary, 'not-uploaded.json') + assert.equal(loadCosMediaManifest({ sourceDirectory, mediaManifestPath: missing }), null) + assert.equal(copyNativeProgram({ ...context.options, mediaManifest: undefined, mediaManifestPath: missing }).sizes.mediaFileCount, 204) + assert.equal(context.report.sizes.mediaFileCount, 204) + assert.equal(fs.existsSync(path.join(context.root, 'utils/cosMedia.js')), false) + const manifest = fakeVerifiedManifest(context) + write(context.temporary, 'verified-test-only.json', JSON.stringify(manifest)) + const options = { ...context.options, mediaManifest: undefined, mediaManifestPath: path.join(context.temporary, 'verified-test-only.json') } + const report = copyNativeProgram(options) + assert.equal(report.sizes.mediaFileCount, 0) + assert.equal(report.media.sourceMediaFiles, 204) + assert.equal(listFiles(context.root).filter(isMediaFile).length, 0) + const state = fs.readFileSync(path.join(context.root, '.native-import-state.json'), 'utf8') + copyNativeProgram(options) + assert.equal(fs.readFileSync(path.join(context.root, '.native-import-state.json'), 'utf8'), state) + assert.equal(fs.readFileSync(path.join(context.outputDirectory, 'app.js'), 'utf8'), '/* retained host entry */') + for (const file of context.sourceManifest.files) { + assert.equal(sha256(fs.readFileSync(path.join(context.sourceDirectory, file.path))), file.sha256, file.path) + } + for (const file of ['platformCore.js', 'storage.js', 'tangPage.js']) { + assert.deepEqual(fs.readFileSync(path.join(context.root, 'utils', file)), fs.readFileSync(path.join(overlayDirectory, 'utils', file)), file) + } +}) + +test('invalid manifest and externally edited old media are rejected before output mutation', t => { + const context = fixture(t, { remote: false }) + const before = fs.readFileSync(path.join(context.root, '.native-import-state.json')) + const invalid = fakeVerifiedManifest(context) + invalid.entries.pop() + assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: invalid }), /media coverage/) + assert.deepEqual(fs.readFileSync(path.join(context.root, '.native-import-state.json')), before) + const edited = sourceMedia.at(-1).path + write(context.root, edited, 'external user edit') + assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: fakeVerifiedManifest(context) }), /Stale native output has external edits/) + assert.equal(fs.readFileSync(path.join(context.root, edited), 'utf8'), 'external user edit') + assert.equal(sha256(fs.readFileSync(path.join(context.root, sourceMedia[0].path))), context.sourceMedia[0].sha256) +}) + +test('all 120 selected comic pages retain source content hashes and 112 formal / 8 provisional status', t => { + const context = fixture(t) + const season = context.require('data/season.js') + const originalSeason = sourceRequire(path.join(sourceDirectory, 'data/season.js')) + const originalPages = sourceRequire(path.join(sourceDirectory, 'package-game/pages/chapter/chapterPages.js')) + const media = context.require('utils/cosMedia.js') + const counts = { pages: 0, formal: 0, provisional: 0 } + for (let number = 1; number <= 15; number++) { + const packageRoot = number === 1 ? 'package-game' : `package-chapter-${String(number).padStart(2, '0')}` + const { buildComicPageModel } = context.require(`${packageRoot}/pages/chapter/chapterPages.js`) + const comic = context.require(`${packageRoot}/utils/comicPageModel.js`) + const { releaseAssets } = context.require(`${packageRoot}/data/releaseAssetManifest.js`) + const chapter = season.chapters[number - 1] + const original = originalPages.buildComicPageModel(originalSeason.chapters[number - 1], number) + const model = buildComicPageModel(chapter, number) + assert.equal(model.pageSequence.length, 8) + for (const [index, page] of model.pageSequence.entries()) { + const sourcePage = original.pageSequence[index] + const originalRecord = sourceManifest.files.find(file => file.path === sourcePage.illustrationAsset.replace(/^\//, '')) + assert.ok(originalRecord, `Original selected source record missing: ${sourcePage.illustrationAsset}`) + const expected = context.hashReplacements.get(originalRecord.sha256) + const assetId = `comic.${page.pageId.toLowerCase().replaceAll('-', '.')}` + const asset = releaseAssets[assetId] + const image = comic.buildComicImageState(page, asset, {}) + assert.equal(media.entryFor(image.src).sha256, expected, page.pageId) + assert.equal(image.src, page.illustrationAsset, page.pageId) + assert.equal(asset.remoteUrl, image.src) + assert.equal(asset.localSeed, '') + assert.match(image.src, /^https:\/\//) + assert.equal(image.source, 'remote-url') + assert.equal(page.playableVisual.formalReleaseEligible, sourcePage.playableVisual.formalReleaseEligible) + assert.equal(page.playableVisual.reviewStatus, sourcePage.playableVisual.reviewStatus) + assert.equal(media.entryFor(image.fallback).kind, 'image') + assert.equal(comic.isPackagedPath(image.src), false) + counts.pages++ + if (page.playableVisual.formalReleaseEligible) counts.formal++ + if (page.playableVisual.runtimeTier === 'experience-provisional') counts.provisional++ + } + } + assert.deepEqual(counts, { pages: 120, formal: 112, provisional: 8 }) + assert.equal(media.resolve('/tang-detective/assets/unknown.jpg'), '') + assert.equal(media.mapMedia({ image: '/tang-detective/assets/unknown.jpg' }).image, '') +}) + +test('source and adapter static images, cast, C01 player art and all eight listening tracks use exact registry URLs', t => { + const context = fixture(t) + const media = context.require('utils/cosMedia.js') + const cover = media.resolve('/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg') + assert.ok(fs.readFileSync(path.join(context.root, 'pages/home/home.wxml'), 'utf8').includes(`src="${cover}"`)) + for (const person of context.require('data/cast.js')) assert.equal(media.entryFor(person.asset).kind, 'image') + let count = 0 + for (const packageRoot of ['package-audio-c01-a', 'package-audio-c01-b']) { + const pages = context.require(`${packageRoot}/data/audioPages.js`) + let player + vm.runInNewContext(fs.readFileSync(path.join(context.root, packageRoot, 'pages/player/player.js'), 'utf8'), { + require(specifier) { + if (specifier.endsWith('/utils/tangPage.js')) return definition => { player = definition } + if (specifier === '../../data/audioPages') return pages + throw new Error(`Unexpected player dependency: ${specifier}`) + }, + }) + assert.equal(player.data.reviewStatus, 'technical-qa-pass-human-listening-pending') + for (const page of Object.values(pages)) { + assert.equal(page.reviewStatus, 'technical-qa-pass-human-listening-pending') + assert.equal(media.entryFor(page.audioSrc).sha256, page.sha256) + assert.equal(media.entryFor(page.imageSrc).kind, 'image') + let prepared = '' + player.onReady.call({ _page: page, _unloaded: false, createAudioContext: src => { prepared = src } }) + assert.equal(prepared, page.audioSrc) + count++ + } + } + assert.equal(count, 8) + const sharePage = fs.readFileSync(path.join(context.root, 'pages/share/share.js'), 'utf8') + assert.ok(sharePage.includes(media.resolve('/assets/share/guixiang-story-share-preview-v1.jpg'))) +}) + +test('remote transport does not open unreviewed audio, promote reserved cues or accept arbitrary HTTPS as approved', async t => { + const context = fixture(t) + const { manager, downloads, releaseAssets } = managerFixture(context) + const pages = context.require('package-game/data/remotePageAudioManifest.js') + const playerPages = context.require('package-audio-player/data/remotePageAudioManifest.js') + const comic = context.require('package-game/utils/comicPageModel.js') + assert.deepEqual(pages.remotePageAudioPages, {}) + assert.deepEqual(playerPages.remotePageAudioPages, {}) + for (let chapter = 2; chapter <= 15; chapter++) { + for (let page = 1; page <= 8; page++) { + const id = `S01-C${String(chapter).padStart(2, '0')}-P${String(page).padStart(2, '0')}` + assert.equal(pages.getApprovedRemotePageAudio(id, releaseAssets), null) + } + } + for (const id of ['audio.cast-audition.v1', 'audio.s01.c02.s01-c02-ms001', 'audio.s01.c03.s01-c03-ms001']) { + assert.equal((await manager.resolve(id)).reason, 'audio-unapproved') + } + const approvedId = 'audio.s01.c01.s01-c01-ms007' + assert.equal(releaseAssets[approvedId].reviewStatus, 'approved') + assert.equal(comic.getReviewedAudioSrc({ status: 'approved', assetId: approvedId, src: releaseAssets[approvedId].remoteUrl }, releaseAssets), '') + assert.equal(comic.getReviewedAudioSrc({ status: 'approved', assetId: approvedId, src: 'https://unknown.example.test/file.mp3' }, releaseAssets), '') + assert.equal(downloads.length, 0) +}) + +test('asset manager downloads exact URLs with an empty CDN base, verifies downloaded/cache bytes, and rejects substituted URLs', async t => { + const context = fixture(t) + const { manager, downloads, files, releaseAssets, managerApi, platform, responses } = managerFixture(context) + const id = 'comic.s01.c01.p01' + const first = await manager.resolve(id) + assert.equal(first.available, true) + assert.equal(first.persistent, true) + assert.equal(downloads[0], releaseAssets[id].remoteUrl) + assert.equal(sha256(files.get(first.uri)), releaseAssets[id].sha256) + assert.equal((await manager.resolve(id)).source, 'cache') + assert.equal(downloads.length, 1) + files.set(first.uri, Buffer.from('tampered cache')) + assert.equal((await manager.resolve(id)).reason, 'remote-integrity-failed') + assert.equal(files.has(first.uri), false) + const second = await manager.resolve(id) + assert.equal(second.available, true) + assert.equal(downloads.length, 2) + const replaced = { ...releaseAssets[id], remoteUrl: 'https://unknown.example.test/bad.jpg' } + const blocked = managerApi.createAssetManager({ manifest: { unregistered: replaced }, assetPlatform: platform }) + assert.equal((await blocked.resolve('unregistered')).reason, 'remote-integrity-failed') + assert.equal(downloads.length, 2) + const badId = 'comic.s01.c01.p03' + responses.set(releaseAssets[badId].remoteUrl, Buffer.from('corrupt download')) + assert.equal((await manager.resolve(badId)).reason, 'remote-integrity-failed') + assert.equal([...files.keys()].some(name => name.startsWith('/test-owned-temp/')), false) +}) + +test('share preview rehashes cache, fails safely on corruption/network errors, and ignores completion after unload', async t => { + const context = fixture(t) + const { manager, files, downloads, failDownloads } = managerFixture(context) + const media = context.require('utils/cosMedia.js') + const page = { data: {}, __tangShowGeneration: 1, __tangVisible: true, + setData(update) { Object.assign(this.data, update) } } + const first = await media.prepareSharePreview(page, manager) + assert.match(first, /^\/test-owned-user-data\//) + assert.equal(page.data.sharePreviewLocalPath, first) + const cached = await media.prepareSharePreview(page, manager) + assert.equal(cached, first) + assert.equal(downloads.length, 1) + files.set(first, Buffer.from('corrupted thumbnail')) + assert.equal(await media.prepareSharePreview(page, manager), '') + assert.equal(page.data.sharePreviewLocalPath, '') + failDownloads(true) + assert.equal(await media.prepareSharePreview(page, manager), '') + failDownloads(false) + assert.ok(await media.prepareSharePreview(page, manager)) + let finish + const delayed = { resolve: () => new Promise(resolve => { finish = resolve }) } + const pending = media.prepareSharePreview(page, delayed) + await Promise.resolve() + page.__tangDead = true + const prior = page.data.sharePreviewLocalPath + finish({ available: true, uri: '/should-not-be-shared' }) + assert.equal(await pending, '') + assert.equal(page.data.sharePreviewLocalPath, prior) + const chapterScript = fs.readFileSync(path.join(context.root, 'package-game/pages/chapter/chapter.js'), 'utf8') + assert.ok(chapterScript.includes('.prepareSharePreview(this, this.getAssetManager())')) + assert.ok(chapterScript.includes("const localPath = '' // Cached share bytes are revalidated")) + assert.ok(!chapterScript.includes('filePath: SHARE_PREVIEW_IMAGE')) +}) + +test('real-output static validator supports COS mode and rejects extra packaged media', t => { + const context = fixture(t) + const report = validateNativeOutput(context.outputDirectory, context.options) + assert.deepEqual(report.errors, []) + assert.equal(report.passed, true) + assert.equal(report.mediaFiles, 204) + assert.equal(report.packagedMediaFiles, 0) + assert.equal(report.mediaMode, 'cos') + write(context.root, 'assets/unowned-video.mp4', 'unexpected packaged video') + const invalid = validateNativeOutput(context.outputDirectory, context.options) + assert.equal(invalid.passed, false) + assert.ok(invalid.errors.includes('Unexpected native packaged media in COS mode')) +}) + +test('actual converted chapter image handler exhausts fallbacks and ignores duplicate/late events across page visits', t => { + const context = fixture(t) + let definition + const modules = new Map() + function load(filename) { + if (!path.extname(filename)) filename += '.js' + if (filename.endsWith('/utils/tangPage.js')) return options => { definition = options } + if (filename.endsWith('/utils/storage.js')) return {} + if (modules.has(filename)) return modules.get(filename).exports + const module = { exports: {} } + modules.set(filename, module) + vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module, + require: specifier => load(path.resolve(path.dirname(filename), specifier)), + }, { filename }) + return module.exports + } + load(path.join(context.root, 'package-game/pages/chapter/chapter.js')) + const chapter = context.require('data/season.js').chapters[0] + const model = context.require('package-game/pages/chapter/chapterPages.js').buildComicPageModel(chapter, 1) + let updates = 0 + const page = { ...definition, data: { ...definition.data, chapter, chapterNumber: 1 }, _comicModel: model, + setData(patch) { updates++; Object.assign(this.data, patch) } } + const reader = index => ({ valid: true, currentPageIndex: index, + currentPageId: model.pageSequence[index].pageId, completedEventIds: [], chapterFinished: false }) + const event = () => ({ currentTarget: { dataset: { + cosPageId: page.data.currentPageId, cosImageSrc: page.data.comicImageSrc, + cosImageGeneration: page.data.comicImageGeneration, + } } }) + page.applyComicReaderState(reader(2), false) + assert.ok(page.data.comicImageActorFallback) + assert.ok(page.data.comicImageFallback) + const originalImage = page.data.comicImageSrc + const originalPage = JSON.stringify(page.data.currentPage) + const interactionEnabled = page.data.currentInteractionEnabled + const firstEvent = event() + page.onComicImageError(firstEvent) + assert.equal(page.data.comicImageSource, 'actor-fallback') + assert.equal(page.data.comicImageSrc, page.data.comicImageActorFallback) + const actorUpdates = updates + page.onComicImageError(firstEvent) + assert.equal(updates, actorUpdates, 'duplicate primary failure must not skip the actor fallback') + const actorEvent = event() + page.onComicImageError(actorEvent) + assert.equal(page.data.comicImageSrc, page.data.comicImageFallback) + const sceneEvent = event() + page.onComicImageError(sceneEvent) + assert.equal(page.data.comicImageSrc, '') + assert.equal(page.data.comicImageSource, 'text-fallback') + assert.ok(page.data.comicImageError) + assert.equal(JSON.stringify(page.data.currentPage), originalPage) + assert.equal(page.data.currentInteractionEnabled, interactionEnabled) + const terminalUpdates = updates + for (let index = 0; index < 10; index++) page.onComicImageError(event()) + page.onComicImageLoad(sceneEvent) + assert.equal(updates, terminalUpdates, 'late events must leave terminal text intact') + page.applyComicReaderState(reader(2), false) + assert.equal(page.data.comicImageSrc, '', 'same-page interactions must not revive a failed URL') + page.applyComicReaderState(reader(3), false) + const nextImage = page.data.comicImageSrc + assert.ok(nextImage) + page.onComicImageError(firstEvent) + assert.equal(page.data.comicImageSrc, nextImage, 'previous-page event must be ignored') + page.applyComicReaderState(reader(2), false) + assert.equal(page.data.comicImageSrc, originalImage, 'revisiting a page starts a new attempt') + page.onComicImageError(firstEvent) + assert.equal(page.data.comicImageSrc, originalImage, 'same page ID from an earlier visit must be ignored') + page.onComicImageError(event()) + assert.equal(page.data.comicImageSrc, page.data.comicImageActorFallback) + const template = fs.readFileSync(path.join(context.root, 'package-game/pages/chapter/chapter.wxml'), 'utf8') + assert.equal((template.match(/data-cos-image-generation=/g) || []).length, + (template.match(/binderror="onComicImageError"/g) || []).length) +}) + +test('output preflight rejects root, file, directory and stale-media symlinks without touching identical external targets', t => { + const context = fixture(t, { remote: false }) + const snapshots = target => { + if (fs.lstatSync(target).isDirectory()) { + return listFiles(target).map(relative => [relative, sha256(fs.readFileSync(path.join(target, relative)))]) + } + return sha256(fs.readFileSync(target)) + } + const scenarios = [ + { target: context.outputDirectory }, + { target: context.root }, + { target: path.join(context.outputDirectory, 'app.json') }, + { target: path.join(context.root, '.native-import-state.json') }, + { target: path.join(context.root, 'utils/storage.js') }, + { target: path.join(context.root, 'utils') }, + { target: path.join(context.root, 'assets/scenes'), remote: true }, + { target: path.join(context.root, sourceMedia.at(-1).path), remote: true }, + { target: path.join(context.root, sourceMedia.at(-1).path), remote: true, dangling: true }, + ] + for (const [index, scenario] of scenarios.entries()) { + const external = path.join(context.temporary, `external-preserved-${index}`) + fs.renameSync(scenario.target, external) + const before = snapshots(external) + fs.symlinkSync(scenario.dangling ? `${external}-missing` : external, scenario.target) + try { + assert.throws(() => copyNativeProgram({ ...context.options, + mediaManifest: scenario.remote ? fakeVerifiedManifest(context) : null }), /Native output forbids symbolic links/) + assert.deepEqual(snapshots(external), before, scenario.target) + } finally { + fs.unlinkSync(scenario.target) + fs.renameSync(external, scenario.target) + } + // Even a cleanup conflict near the end must leave earlier media untouched. + assert.equal(sha256(fs.readFileSync(path.join(context.root, sourceMedia[0].path))), context.sourceMedia[0].sha256) + } + const externalNewFile = path.join(context.temporary, 'same-content-new-helper.js') + const newHelper = path.join(context.root, 'utils/cosMedia.js') + fs.copyFileSync(path.join(overlayDirectory, 'utils/cosMedia.js'), externalNewFile) + fs.symlinkSync(externalNewFile, newHelper) + const newHash = sha256(fs.readFileSync(externalNewFile)) + try { + assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: fakeVerifiedManifest(context) }), /Native output forbids symbolic links/) + assert.equal(sha256(fs.readFileSync(externalNewFile)), newHash) + } finally { fs.unlinkSync(newHelper) } + const alias = path.join(context.temporary, 'ancestor-alias') + fs.symlinkSync(context.temporary, alias, 'dir') + assert.equal(copyNativeProgram({ ...context.options, outputDirectory: path.join(alias, 'output') }).sizes.mediaFileCount, 204) + const local = validateNativeOutput(context.outputDirectory, context.options) + assert.deepEqual(local.errors, []) + assert.equal(local.passed, true) + assert.equal(local.mediaMode, 'local') + assert.equal(local.packagedMediaFiles, 204) + assert.ok(local.relativeDependencies > 300) +}) diff --git a/TongjiUniApp/build/tang-detective-media-prune-receipt.json b/TongjiUniApp/build/tang-detective-media-prune-receipt.json new file mode 100644 index 0000000..0f890d0 --- /dev/null +++ b/TongjiUniApp/build/tang-detective-media-prune-receipt.json @@ -0,0 +1,1714 @@ +{ + "schemaVersion": 1, + "status": "completed", + "uploadRunId": "b9ab703a-49a6-4b6b-ac34-979eca1d4828", + "sourceManifestSha256": "3b203406a31fdbe17c770aeea0bab19755acfedf3fa1595641ee4e15a9712e20", + "mediaManifestSha256": "0062a1edbca7d08cba3c21fc226f419def91d8dfd644f41b9e35c2543ef508a0", + "backup": { + "sha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61", + "bytes": 23882890, + "format": "tar.gz" + }, + "entries": [ + { + "sourcePath": "assets/characters/female-cook.jpg", + "bytes": 11958, + "sha256": "c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f", + "objectKey": "uploads/images/20260908/20260908172953806b83817.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172953806b83817.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/characters/lele.jpg", + "bytes": 102681, + "sha256": "807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168", + "objectKey": "uploads/images/20260908/20260908172954a3c700294.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954a3c700294.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/characters/lin-xiulan.jpg", + "bytes": 107355, + "sha256": "56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361", + "objectKey": "uploads/images/20260908/202609081729547cbb37718.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081729547cbb37718.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/characters/qin-xiaoman.jpg", + "bytes": 92914, + "sha256": "22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5", + "objectKey": "uploads/images/20260908/2026090817295416f2f2841.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817295416f2f2841.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/characters/qin-zhicheng.jpg", + "bytes": 116799, + "sha256": "7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2", + "objectKey": "uploads/images/20260908/202609081729541a4a74577.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081729541a4a74577.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/characters/tang-mingyuan.jpg", + "bytes": 92880, + "sha256": "5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb", + "objectKey": "uploads/images/20260908/20260908172954fef263003.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954fef263003.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/characters/tang-shouan.jpg", + "bytes": 108663, + "sha256": "4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89", + "objectKey": "uploads/images/20260908/20260908172954a75561985.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954a75561985.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/characters/xiaozhen.jpg", + "bytes": 89720, + "sha256": "d5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb", + "objectKey": "uploads/images/20260908/20260908172954578d88427.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954578d88427.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/characters/zhao-jianguo.jpg", + "bytes": 100165, + "sha256": "1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0", + "objectKey": "uploads/images/20260908/2026090817295420b991364.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817295420b991364.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg", + "bytes": 97655, + "sha256": "f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0", + "objectKey": "uploads/images/20260908/20260908172954af5606377.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954af5606377.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/scenes/gui-xiang-1978.jpg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "objectKey": "uploads/images/20260908/2026090817303974c831720.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303974c831720.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/scenes/gui-xiang-1995.jpg", + "bytes": 67246, + "sha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f", + "objectKey": "uploads/images/20260908/20260908173040de54b5498.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173040de54b5498.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/scenes/gui-xiang-1998.jpg", + "bytes": 62727, + "sha256": "d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14", + "objectKey": "uploads/images/20260908/2026090817303983bc35301.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303983bc35301.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/scenes/gui-xiang-2001.jpg", + "bytes": 59408, + "sha256": "9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8", + "objectKey": "uploads/images/20260908/20260908173039ad5bc2437.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039ad5bc2437.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/scenes/gui-xiang-2003.jpg", + "bytes": 53822, + "sha256": "68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431", + "objectKey": "uploads/images/20260908/2026090817303985ee07761.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303985ee07761.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/scenes/gui-xiang-2008.jpg", + "bytes": 53286, + "sha256": "c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5", + "objectKey": "uploads/images/20260908/2026090817303994b4b1401.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303994b4b1401.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "objectKey": "uploads/images/20260908/20260908173039628910741.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039628910741.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "assets/share/guixiang-story-share-preview-v1.jpg", + "bytes": 99232, + "sha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69", + "objectKey": "uploads/images/20260908/202609081718480ee488780.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081718480ee488780.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P01.daa8f04a2627.mp3", + "bytes": 270139, + "sha256": "daa8f04a262767109f3a0621f178e3c58f169507e8ea09fa8d583258cb713282", + "objectKey": "uploads/voice/20260908/1788859559652-e90yvvdk.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859559652-e90yvvdk.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P02.7774981b1878.mp3", + "bytes": 396572, + "sha256": "7774981b1878f6ad882cb7a4e3f923d67f43c12c2ea068982df1cab63820d8c2", + "objectKey": "uploads/voice/20260908/1788859640935-23ixzy68.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859640935-23ixzy68.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P03.0f21daa7eccb.mp3", + "bytes": 244853, + "sha256": "0f21daa7eccbaac9139317e8209570fe025fb2403ea5cc794e12586764365885", + "objectKey": "uploads/voice/20260908/1788859681917-n7wb94nh.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859681917-n7wb94nh.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-a/assets/audio/S01-C01-P04.ed65c367d761.mp3", + "bytes": 375047, + "sha256": "ed65c367d76125e1404d76bb86faa0629b599f6853a39e58821617f854bfa167", + "objectKey": "uploads/voice/20260908/1788859683117-o8y8c1ap.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859683117-o8y8c1ap.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-a/assets/player-art/S01-C01-P01-title-v1.jpg", + "bytes": 105278, + "sha256": "12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a", + "objectKey": "uploads/images/20260908/20260908173039668f11872.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039668f11872.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-a/assets/player-art/S01-C01-P02-missing-table-ensemble-v1.jpg", + "bytes": 97655, + "sha256": "f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0", + "objectKey": "uploads/images/20260908/20260908172954af5606377.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908172954af5606377.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-a/assets/player-art/S01-C01-P03-H01-scan-only-v1.jpg", + "bytes": 91819, + "sha256": "6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80", + "objectKey": "uploads/images/20260908/202609081730407a8a27957.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081730407a8a27957.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-a/assets/player-art/S01-C01-P04-H02-extra-dish-v1.jpg", + "bytes": 120832, + "sha256": "bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce", + "objectKey": "uploads/images/20260908/202609081730409483f1003.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081730409483f1003.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P05.210005d24e32.mp3", + "bytes": 263661, + "sha256": "210005d24e3276e2689e38b0bb3a9d6b6e900ca3cc5ca874278a0b89a50def6a", + "objectKey": "uploads/voice/20260908/1788859684272-vx02qyha.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859684272-vx02qyha.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P06.5e69ed4c17f3.mp3", + "bytes": 107344, + "sha256": "5e69ed4c17f3d5c4168546c0b76a6d5bfb13ecbecdd982c5be1c27259b9ce1a4", + "objectKey": "uploads/voice/20260908/1788859685309-bq8hczp2.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859685309-bq8hczp2.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P07.a68bc0cb8ecc.mp3", + "bytes": 84356, + "sha256": "a68bc0cb8ecc6d53b7c877567d6c21fdd894c914e84c072357116429d05d0cc5", + "objectKey": "uploads/voice/20260908/1788859686361-f4myg1jo.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859686361-f4myg1jo.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-b/assets/audio/S01-C01-P08.1c44eff9d71f.mp3", + "bytes": 316951, + "sha256": "1c44eff9d71fcd004e53050e0727ee37fa3560509d3579c0e2cd004e80e246ec", + "objectKey": "uploads/voice/20260908/1788859687423-at9k5kro.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859687423-at9k5kro.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-b/assets/player-art/S01-C01-P05-H03-four-imprints-v1.jpg", + "bytes": 111834, + "sha256": "d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f", + "objectKey": "uploads/images/20260908/2026090817310719bb62047.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817310719bb62047.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-b/assets/player-art/S01-C01-P06-H04-unsaid-speech-v1.jpg", + "bytes": 120294, + "sha256": "7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750", + "objectKey": "uploads/images/20260908/202609081731073a6bf7270.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731073a6bf7270.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-b/assets/player-art/S01-C01-P07-EM01-call-zhao-in-v1.jpg", + "bytes": 96418, + "sha256": "4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31", + "objectKey": "uploads/images/20260908/20260908173107b3c447822.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173107b3c447822.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-audio-c01-b/assets/player-art/S01-C01-P08-cliffhanger-v1.jpg", + "bytes": 99232, + "sha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69", + "objectKey": "uploads/images/20260908/202609081718480ee488780.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081718480ee488780.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS001.mp3", + "bytes": 19125, + "sha256": "580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233", + "objectKey": "uploads/voice/20260908/1788859688761-rv0vx59b.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859688761-rv0vx59b.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS002-ATTR.mp3", + "bytes": 4797, + "sha256": "aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8", + "objectKey": "uploads/voice/20260908/1788859689813-2u5eyx28.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859689813-2u5eyx28.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS002.mp3", + "bytes": 6417, + "sha256": "ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe", + "objectKey": "uploads/voice/20260908/1788859705645-v0njwhr6.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859705645-v0njwhr6.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS003.mp3", + "bytes": 19053, + "sha256": "3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f", + "objectKey": "uploads/voice/20260908/1788859706721-j2czunoh.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859706721-j2czunoh.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS004.mp3", + "bytes": 8505, + "sha256": "e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c", + "objectKey": "uploads/voice/20260908/1788859707781-5eif2sva.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859707781-5eif2sva.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS005.mp3", + "bytes": 7749, + "sha256": "6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e", + "objectKey": "uploads/voice/20260908/1788859708857-lgkflk7r.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859708857-lgkflk7r.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS006.mp3", + "bytes": 12465, + "sha256": "067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88", + "objectKey": "uploads/voice/20260908/1788859709963-w40dseme.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859709963-w40dseme.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS007.mp3", + "bytes": 12213, + "sha256": "7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c", + "objectKey": "uploads/voice/20260908/1788859710994-9ja52nj4.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859710994-9ja52nj4.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS008.mp3", + "bytes": 12465, + "sha256": "288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f", + "objectKey": "uploads/voice/20260908/1788859712054-pqgtblz7.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859712054-pqgtblz7.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS009.mp3", + "bytes": 6993, + "sha256": "5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2", + "objectKey": "uploads/voice/20260908/1788859713116-rtg8ou4j.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859713116-rtg8ou4j.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS010.mp3", + "bytes": 27189, + "sha256": "d371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d", + "objectKey": "uploads/voice/20260908/1788859714260-41ehj12t.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859714260-41ehj12t.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS011.mp3", + "bytes": 15741, + "sha256": "2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb", + "objectKey": "uploads/voice/20260908/1788859715307-zvzm6c52.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859715307-zvzm6c52.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MS012.mp3", + "bytes": 32697, + "sha256": "33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a", + "objectKey": "uploads/voice/20260908/1788859716357-ddolkq84.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859716357-ddolkq84.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-MT000.mp3", + "bytes": 4005, + "sha256": "5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e", + "objectKey": "uploads/voice/20260908/1788859717407-joxtjydj.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859717407-joxtjydj.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/audio/S01-C02-TE900.mp3", + "bytes": 7713, + "sha256": "d57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1", + "objectKey": "uploads/voice/20260908/1788859718453-7taqslda.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859718453-7taqslda.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg", + "bytes": 103969, + "sha256": "c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552", + "objectKey": "uploads/images/20260908/202609081731074bb984948.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731074bb984948.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg", + "bytes": 128391, + "sha256": "892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea", + "objectKey": "uploads/images/20260908/202609081731073a9ad6102.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731073a9ad6102.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg", + "bytes": 110191, + "sha256": "e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c", + "objectKey": "uploads/images/20260908/202609081731076ef7d1167.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731076ef7d1167.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg", + "bytes": 125153, + "sha256": "ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593", + "objectKey": "uploads/images/20260908/202609081731075b3e30712.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731075b3e30712.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg", + "bytes": 128039, + "sha256": "cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d", + "objectKey": "uploads/images/20260908/202609081731072dfe55233.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731072dfe55233.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg", + "bytes": 99846, + "sha256": "61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541", + "objectKey": "uploads/images/20260908/202609081731078ac2d7004.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731078ac2d7004.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg", + "bytes": 133519, + "sha256": "c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539", + "objectKey": "uploads/images/20260908/20260908173107466700017.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173107466700017.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg", + "bytes": 128463, + "sha256": "6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703", + "objectKey": "uploads/images/20260908/20260908173112ff8887242.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112ff8887242.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-02/assets/scenes/gui-xiang-1978.jpg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "objectKey": "uploads/images/20260908/2026090817303974c831720.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303974c831720.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS001.mp3", + "bytes": 25821, + "sha256": "b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43", + "objectKey": "uploads/voice/20260908/1788859719556-4h5g177p.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859719556-4h5g177p.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS002-ATTR.mp3", + "bytes": 10557, + "sha256": "ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd", + "objectKey": "uploads/voice/20260908/1788859720601-tm2rratq.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859720601-tm2rratq.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS002.mp3", + "bytes": 6489, + "sha256": "c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c", + "objectKey": "uploads/voice/20260908/1788859735790-h24dj5jw.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859735790-h24dj5jw.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS003.mp3", + "bytes": 20205, + "sha256": "af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184", + "objectKey": "uploads/voice/20260908/1788859736846-tu8raupk.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859736846-tu8raupk.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS004.mp3", + "bytes": 7749, + "sha256": "c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da", + "objectKey": "uploads/voice/20260908/1788859738725-lzs4obsj.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859738725-lzs4obsj.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS005-ATTR.mp3", + "bytes": 5517, + "sha256": "4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e", + "objectKey": "uploads/voice/20260908/1788859739954-0sdopsv6.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859739954-0sdopsv6.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS005.mp3", + "bytes": 10053, + "sha256": "46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442", + "objectKey": "uploads/voice/20260908/1788859741040-tnxabkmf.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859741040-tnxabkmf.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS006.mp3", + "bytes": 13401, + "sha256": "2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5", + "objectKey": "uploads/voice/20260908/1788859742080-q1gq96gr.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859742080-q1gq96gr.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS007-ATTR.mp3", + "bytes": 6309, + "sha256": "f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0", + "objectKey": "uploads/voice/20260908/1788859743113-kgit8i2r.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859743113-kgit8i2r.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS007.mp3", + "bytes": 6849, + "sha256": "68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038", + "objectKey": "uploads/voice/20260908/1788859744174-pg41290p.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859744174-pg41290p.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS008.mp3", + "bytes": 5373, + "sha256": "d9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7", + "objectKey": "uploads/voice/20260908/1788859745258-2u7m9wc0.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859745258-2u7m9wc0.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS009.mp3", + "bytes": 20925, + "sha256": "97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed", + "objectKey": "uploads/voice/20260908/1788859746348-qrqomq07.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859746348-qrqomq07.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS010.mp3", + "bytes": 6165, + "sha256": "49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0", + "objectKey": "uploads/voice/20260908/1788859747502-csqqmkys.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859747502-csqqmkys.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS011.mp3", + "bytes": 15777, + "sha256": "6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25", + "objectKey": "uploads/voice/20260908/1788859748680-hx5gjcjt.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859748680-hx5gjcjt.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS013.mp3", + "bytes": 19017, + "sha256": "8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a", + "objectKey": "uploads/voice/20260908/1788859749772-njzzx6f8.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859749772-njzzx6f8.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS014.mp3", + "bytes": 4869, + "sha256": "8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0", + "objectKey": "uploads/voice/20260908/1788859750856-ubrs0bjv.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859750856-ubrs0bjv.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MS015.mp3", + "bytes": 14193, + "sha256": "661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb", + "objectKey": "uploads/voice/20260908/1788859751906-i84qzugn.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859751906-i84qzugn.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-MT000.mp3", + "bytes": 4077, + "sha256": "374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65", + "objectKey": "uploads/voice/20260908/1788859752961-k632st94.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859752961-k632st94.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/audio/S01-C03-TE900.mp3", + "bytes": 6813, + "sha256": "f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993", + "objectKey": "uploads/voice/20260908/1788859754110-nyj2b3le.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859754110-nyj2b3le.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg", + "bytes": 118129, + "sha256": "7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4", + "objectKey": "uploads/images/20260908/20260908173112e49c90827.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112e49c90827.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg", + "bytes": 194945, + "sha256": "feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5", + "objectKey": "uploads/images/20260908/20260908173112365a59120.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112365a59120.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg", + "bytes": 207111, + "sha256": "841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e", + "objectKey": "uploads/images/20260908/20260908173112d30437983.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112d30437983.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg", + "bytes": 199826, + "sha256": "8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727", + "objectKey": "uploads/images/20260908/202609081731127140b3844.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731127140b3844.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg", + "bytes": 171496, + "sha256": "cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79", + "objectKey": "uploads/images/20260908/20260908173112353247130.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112353247130.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg", + "bytes": 84540, + "sha256": "00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1", + "objectKey": "uploads/images/20260908/20260908173112169de0256.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112169de0256.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg", + "bytes": 182868, + "sha256": "787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602", + "objectKey": "uploads/images/20260908/20260908173112a505f4772.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112a505f4772.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg", + "bytes": 210097, + "sha256": "056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5", + "objectKey": "uploads/images/20260908/202609081731122b3399354.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731122b3399354.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-03/assets/scenes/gui-xiang-1978.jpg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "objectKey": "uploads/images/20260908/2026090817303974c831720.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303974c831720.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg", + "bytes": 83596, + "sha256": "e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30", + "objectKey": "uploads/images/20260908/20260908173112c4da43174.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173112c4da43174.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg", + "bytes": 105099, + "sha256": "d83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505", + "objectKey": "uploads/images/20260908/2026090817311649e2a9550.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817311649e2a9550.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg", + "bytes": 91939, + "sha256": "c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4", + "objectKey": "uploads/images/20260908/20260908173116162c66104.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116162c66104.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg", + "bytes": 89468, + "sha256": "70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7", + "objectKey": "uploads/images/20260908/20260908173116a53f24003.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116a53f24003.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg", + "bytes": 93259, + "sha256": "4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a", + "objectKey": "uploads/images/20260908/202609081731162a2021285.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731162a2021285.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg", + "bytes": 92724, + "sha256": "25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2", + "objectKey": "uploads/images/20260908/20260908173116813c87413.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116813c87413.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg", + "bytes": 98885, + "sha256": "3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d", + "objectKey": "uploads/images/20260908/20260908173116bfec43397.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116bfec43397.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg", + "bytes": 104015, + "sha256": "9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61", + "objectKey": "uploads/images/20260908/20260908173116dbdc29125.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116dbdc29125.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-04/assets/scenes/gui-xiang-1978.jpg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "objectKey": "uploads/images/20260908/2026090817303974c831720.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303974c831720.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg", + "bytes": 113102, + "sha256": "52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef", + "objectKey": "uploads/images/20260908/20260908173116c5c618420.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116c5c618420.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg", + "bytes": 156680, + "sha256": "897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb", + "objectKey": "uploads/images/20260908/2026090817311649ffe3535.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817311649ffe3535.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg", + "bytes": 147490, + "sha256": "76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22", + "objectKey": "uploads/images/20260908/20260908173116ecae73791.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173116ecae73791.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg", + "bytes": 124380, + "sha256": "c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c", + "objectKey": "uploads/images/20260908/202609081731352d7e82556.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731352d7e82556.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg", + "bytes": 159817, + "sha256": "a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8", + "objectKey": "uploads/images/20260908/2026090817313559ab83276.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313559ab83276.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg", + "bytes": 146026, + "sha256": "63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca", + "objectKey": "uploads/images/20260908/2026090817313533e949160.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313533e949160.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg", + "bytes": 150951, + "sha256": "172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567", + "objectKey": "uploads/images/20260908/202609081731350aa1d5788.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731350aa1d5788.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg", + "bytes": 190776, + "sha256": "b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f", + "objectKey": "uploads/images/20260908/202609081731357238e1677.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731357238e1677.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-05/assets/scenes/gui-xiang-1978.jpg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5", + "objectKey": "uploads/images/20260908/2026090817303974c831720.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303974c831720.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg", + "bytes": 190405, + "sha256": "f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40", + "objectKey": "uploads/images/20260908/20260908173135b00ff3493.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173135b00ff3493.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg", + "bytes": 140303, + "sha256": "0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae", + "objectKey": "uploads/images/20260908/20260908173135595af4252.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173135595af4252.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg", + "bytes": 181320, + "sha256": "caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75", + "objectKey": "uploads/images/20260908/202609081731352d9fb1212.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731352d9fb1212.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg", + "bytes": 150118, + "sha256": "f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469", + "objectKey": "uploads/images/20260908/2026090817313638f109229.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313638f109229.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg", + "bytes": 154740, + "sha256": "408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d", + "objectKey": "uploads/images/20260908/2026090817313660b804375.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313660b804375.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg", + "bytes": 142954, + "sha256": "84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53", + "objectKey": "uploads/images/20260908/20260908173138a61767853.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138a61767853.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg", + "bytes": 152752, + "sha256": "c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b", + "objectKey": "uploads/images/20260908/20260908173138fa0762681.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138fa0762681.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg", + "bytes": 170210, + "sha256": "bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5", + "objectKey": "uploads/images/20260908/2026090817313839b279656.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313839b279656.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-06/assets/scenes/gui-xiang-1995.jpg", + "bytes": 67246, + "sha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f", + "objectKey": "uploads/images/20260908/20260908173040de54b5498.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173040de54b5498.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg", + "bytes": 165311, + "sha256": "42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720", + "objectKey": "uploads/images/20260908/20260908173138dff5a5536.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138dff5a5536.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg", + "bytes": 173413, + "sha256": "81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2", + "objectKey": "uploads/images/20260908/20260908173138191298369.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138191298369.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg", + "bytes": 128377, + "sha256": "fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5", + "objectKey": "uploads/images/20260908/20260908173138ec8e94150.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138ec8e94150.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg", + "bytes": 147055, + "sha256": "8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3", + "objectKey": "uploads/images/20260908/20260908173138b2b761219.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138b2b761219.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg", + "bytes": 157850, + "sha256": "5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4", + "objectKey": "uploads/images/20260908/20260908173138c9d001251.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173138c9d001251.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg", + "bytes": 174837, + "sha256": "7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a", + "objectKey": "uploads/images/20260908/20260908173139f46863978.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173139f46863978.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg", + "bytes": 130849, + "sha256": "19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5", + "objectKey": "uploads/images/20260908/2026090817313967a307026.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817313967a307026.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg", + "bytes": 161095, + "sha256": "f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e", + "objectKey": "uploads/images/20260908/202609081731411da1c2125.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731411da1c2125.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-07/assets/scenes/gui-xiang-1995.jpg", + "bytes": 67246, + "sha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f", + "objectKey": "uploads/images/20260908/20260908173040de54b5498.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173040de54b5498.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg", + "bytes": 157367, + "sha256": "b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc", + "objectKey": "uploads/images/20260908/20260908173141cf0c01964.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173141cf0c01964.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg", + "bytes": 191697, + "sha256": "4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562", + "objectKey": "uploads/images/20260908/20260908173142053ed9914.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142053ed9914.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg", + "bytes": 198741, + "sha256": "2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c", + "objectKey": "uploads/images/20260908/20260908173142ef5784670.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142ef5784670.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg", + "bytes": 178634, + "sha256": "737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935", + "objectKey": "uploads/images/20260908/2026090817314274cfe2605.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817314274cfe2605.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg", + "bytes": 180813, + "sha256": "41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618", + "objectKey": "uploads/images/20260908/202609081731420bc315230.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731420bc315230.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg", + "bytes": 163905, + "sha256": "fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111", + "objectKey": "uploads/images/20260908/20260908173142d16d12935.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142d16d12935.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg", + "bytes": 116785, + "sha256": "6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea", + "objectKey": "uploads/images/20260908/202609081731425a0819698.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731425a0819698.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg", + "bytes": 138725, + "sha256": "2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743", + "objectKey": "uploads/images/20260908/202609081731429844c1305.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731429844c1305.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-08/assets/scenes/gui-xiang-1998.jpg", + "bytes": 62727, + "sha256": "d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14", + "objectKey": "uploads/images/20260908/2026090817303983bc35301.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303983bc35301.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg", + "bytes": 150476, + "sha256": "59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f", + "objectKey": "uploads/images/20260908/20260908173142346106887.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173142346106887.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg", + "bytes": 179019, + "sha256": "0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d", + "objectKey": "uploads/images/20260908/20260908173145f82762147.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145f82762147.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg", + "bytes": 192720, + "sha256": "9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af", + "objectKey": "uploads/images/20260908/202609081731451a0a48902.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731451a0a48902.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg", + "bytes": 139199, + "sha256": "cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff", + "objectKey": "uploads/images/20260908/20260908173145e43cc7774.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145e43cc7774.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg", + "bytes": 164727, + "sha256": "5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a", + "objectKey": "uploads/images/20260908/20260908173145050809349.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145050809349.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg", + "bytes": 180695, + "sha256": "5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443", + "objectKey": "uploads/images/20260908/202609081731456267e0864.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731456267e0864.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg", + "bytes": 176826, + "sha256": "9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f", + "objectKey": "uploads/images/20260908/202609081731451e62d8082.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731451e62d8082.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg", + "bytes": 152317, + "sha256": "b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724", + "objectKey": "uploads/images/20260908/2026090817314563ce19085.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817314563ce19085.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-09/assets/scenes/gui-xiang-2001.jpg", + "bytes": 59408, + "sha256": "9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8", + "objectKey": "uploads/images/20260908/20260908173039ad5bc2437.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039ad5bc2437.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg", + "bytes": 170147, + "sha256": "42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8", + "objectKey": "uploads/images/20260908/20260908173145234414957.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145234414957.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg", + "bytes": 201027, + "sha256": "30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55", + "objectKey": "uploads/images/20260908/2026090817314577b3d2877.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817314577b3d2877.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg", + "bytes": 145630, + "sha256": "23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9", + "objectKey": "uploads/images/20260908/20260908173145d06c49932.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173145d06c49932.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg", + "bytes": 142107, + "sha256": "be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde", + "objectKey": "uploads/images/20260908/202609081732037cfc44506.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732037cfc44506.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg", + "bytes": 189031, + "sha256": "a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd", + "objectKey": "uploads/images/20260908/202609081732034eb7f0368.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732034eb7f0368.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg", + "bytes": 161904, + "sha256": "6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446", + "objectKey": "uploads/images/20260908/202609081732039e6701414.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732039e6701414.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg", + "bytes": 157096, + "sha256": "13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99", + "objectKey": "uploads/images/20260908/2026090817320373f998007.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320373f998007.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg", + "bytes": 205032, + "sha256": "2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0", + "objectKey": "uploads/images/20260908/20260908173203dace72023.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173203dace72023.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-10/assets/scenes/gui-xiang-2003.jpg", + "bytes": 53822, + "sha256": "68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431", + "objectKey": "uploads/images/20260908/2026090817303985ee07761.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303985ee07761.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg", + "bytes": 170560, + "sha256": "840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a", + "objectKey": "uploads/images/20260908/202609081732035f2e17452.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732035f2e17452.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg", + "bytes": 122506, + "sha256": "112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1", + "objectKey": "uploads/images/20260908/20260908173203e27952820.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173203e27952820.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg", + "bytes": 95514, + "sha256": "23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51", + "objectKey": "uploads/images/20260908/202609081732032db544840.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732032db544840.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg", + "bytes": 185485, + "sha256": "3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50", + "objectKey": "uploads/images/20260908/202609081732035c9d61064.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732035c9d61064.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg", + "bytes": 120040, + "sha256": "c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5", + "objectKey": "uploads/images/20260908/202609081732037c8583818.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732037c8583818.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg", + "bytes": 172410, + "sha256": "4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c", + "objectKey": "uploads/images/20260908/2026090817320602c476049.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320602c476049.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg", + "bytes": 157302, + "sha256": "9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a", + "objectKey": "uploads/images/20260908/20260908173206dae776115.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206dae776115.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg", + "bytes": 112557, + "sha256": "b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de", + "objectKey": "uploads/images/20260908/202609081732065676e2434.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732065676e2434.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-11/assets/scenes/gui-xiang-2008.jpg", + "bytes": 53286, + "sha256": "c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5", + "objectKey": "uploads/images/20260908/2026090817303994b4b1401.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817303994b4b1401.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg", + "bytes": 167948, + "sha256": "e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a", + "objectKey": "uploads/images/20260908/20260908173206a7ebe7583.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206a7ebe7583.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg", + "bytes": 190911, + "sha256": "6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602", + "objectKey": "uploads/images/20260908/20260908173206cb3721071.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206cb3721071.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg", + "bytes": 133362, + "sha256": "c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9", + "objectKey": "uploads/images/20260908/20260908173206b42891414.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206b42891414.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg", + "bytes": 152941, + "sha256": "1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e", + "objectKey": "uploads/images/20260908/2026090817320615d6c3156.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320615d6c3156.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg", + "bytes": 164550, + "sha256": "695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0", + "objectKey": "uploads/images/20260908/20260908173206946d11690.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173206946d11690.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg", + "bytes": 153998, + "sha256": "9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76", + "objectKey": "uploads/images/20260908/202609081732067211a5043.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732067211a5043.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg", + "bytes": 195056, + "sha256": "338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5", + "objectKey": "uploads/images/20260908/2026090817320628b5f1545.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817320628b5f1545.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg", + "bytes": 135599, + "sha256": "a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f", + "objectKey": "uploads/images/20260908/20260908173210f33956967.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210f33956967.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-12/assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "objectKey": "uploads/images/20260908/20260908173039628910741.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039628910741.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg", + "bytes": 191222, + "sha256": "2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e", + "objectKey": "uploads/images/20260908/20260908173210dcc904301.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210dcc904301.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg", + "bytes": 201951, + "sha256": "a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198", + "objectKey": "uploads/images/20260908/202609081732104dda17591.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732104dda17591.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg", + "bytes": 201320, + "sha256": "13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b", + "objectKey": "uploads/images/20260908/202609081732103e1737129.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732103e1737129.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg", + "bytes": 173154, + "sha256": "d4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d", + "objectKey": "uploads/images/20260908/20260908173210052871552.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210052871552.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg", + "bytes": 214867, + "sha256": "2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff", + "objectKey": "uploads/images/20260908/202609081732106acce6348.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732106acce6348.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg", + "bytes": 184565, + "sha256": "8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d", + "objectKey": "uploads/images/20260908/2026090817321063e243356.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321063e243356.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg", + "bytes": 198924, + "sha256": "edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b", + "objectKey": "uploads/images/20260908/202609081732103c91a8938.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732103c91a8938.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg", + "bytes": 179621, + "sha256": "f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc", + "objectKey": "uploads/images/20260908/20260908173210e3e400579.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210e3e400579.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-13/assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "objectKey": "uploads/images/20260908/20260908173039628910741.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039628910741.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg", + "bytes": 173212, + "sha256": "6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2", + "objectKey": "uploads/images/20260908/20260908173210a1bfa8588.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173210a1bfa8588.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg", + "bytes": 204940, + "sha256": "15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48", + "objectKey": "uploads/images/20260908/2026090817321402ddc8471.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321402ddc8471.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg", + "bytes": 181140, + "sha256": "9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365", + "objectKey": "uploads/images/20260908/20260908173214a4a8d4074.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214a4a8d4074.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg", + "bytes": 190877, + "sha256": "899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e", + "objectKey": "uploads/images/20260908/202609081732148dba50220.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732148dba50220.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg", + "bytes": 181882, + "sha256": "b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801", + "objectKey": "uploads/images/20260908/202609081732146ee292192.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732146ee292192.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg", + "bytes": 192247, + "sha256": "c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd", + "objectKey": "uploads/images/20260908/2026090817321455aa99619.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321455aa99619.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg", + "bytes": 166223, + "sha256": "d0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d", + "objectKey": "uploads/images/20260908/20260908173214765e24253.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214765e24253.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg", + "bytes": 172139, + "sha256": "09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c", + "objectKey": "uploads/images/20260908/20260908173214ac7b62068.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214ac7b62068.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-14/assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "objectKey": "uploads/images/20260908/20260908173039628910741.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039628910741.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg", + "bytes": 207370, + "sha256": "194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab", + "objectKey": "uploads/images/20260908/202609081732143d4070553.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732143d4070553.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg", + "bytes": 189454, + "sha256": "b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b", + "objectKey": "uploads/images/20260908/2026090817321405dc63371.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817321405dc63371.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg", + "bytes": 162103, + "sha256": "b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173", + "objectKey": "uploads/images/20260908/20260908173214896801232.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173214896801232.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg", + "bytes": 138672, + "sha256": "2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea", + "objectKey": "uploads/images/20260908/20260908173218201a61275.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173218201a61275.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg", + "bytes": 166021, + "sha256": "21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a", + "objectKey": "uploads/images/20260908/20260908173218191279663.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173218191279663.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg", + "bytes": 172654, + "sha256": "3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50", + "objectKey": "uploads/images/20260908/202609081732189a41f9598.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732189a41f9598.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg", + "bytes": 210429, + "sha256": "98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98", + "objectKey": "uploads/images/20260908/202609081732189ef359918.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081732189ef359918.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg", + "bytes": 96215, + "sha256": "520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113", + "objectKey": "uploads/images/20260908/20260908173218765170829.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173218765170829.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-chapter-15/assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "objectKey": "uploads/images/20260908/20260908173039628910741.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039628910741.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-game/assets/audio/S01-C01-MS007.mp3", + "bytes": 18957, + "sha256": "1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8", + "objectKey": "uploads/voice/20260908/1788859755676-8bbjnyhb.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859755676-8bbjnyhb.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-game/assets/audio/S01-C01-MS010.mp3", + "bytes": 17421, + "sha256": "628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597", + "objectKey": "uploads/voice/20260908/1788859756823-r37bn776.mp3", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/voice/20260908/1788859756823-r37bn776.mp3", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg", + "bytes": 105278, + "sha256": "12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a", + "objectKey": "uploads/images/20260908/20260908173039668f11872.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039668f11872.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg", + "bytes": 91819, + "sha256": "6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80", + "objectKey": "uploads/images/20260908/202609081730407a8a27957.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081730407a8a27957.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg", + "bytes": 120832, + "sha256": "bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce", + "objectKey": "uploads/images/20260908/202609081730409483f1003.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081730409483f1003.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg", + "bytes": 111834, + "sha256": "d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f", + "objectKey": "uploads/images/20260908/2026090817310719bb62047.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/2026090817310719bb62047.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg", + "bytes": 120294, + "sha256": "7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750", + "objectKey": "uploads/images/20260908/202609081731073a6bf7270.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081731073a6bf7270.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg", + "bytes": 96418, + "sha256": "4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31", + "objectKey": "uploads/images/20260908/20260908173107b3c447822.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173107b3c447822.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg", + "bytes": 99232, + "sha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69", + "objectKey": "uploads/images/20260908/202609081718480ee488780.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081718480ee488780.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + }, + { + "sourcePath": "package-game/assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7", + "objectKey": "uploads/images/20260908/20260908173039628910741.jpg", + "url": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/20260908173039628910741.jpg", + "backupSha256": "656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61" + } + ], + "additionalUnusedFiles": [ + { + "projectPath": "static/background.svg", + "bytes": 934, + "sha256": "5dbe64f7f7ae2396d88f18ca222b1cf5ac73eb0dcefec69cea38c567b888c0d1", + "reason": "no-runtime-reference" + }, + { + "projectPath": "static/calling-logo.png", + "bytes": 5606, + "sha256": "8780ae8637c142e04d2f6defc9dd5a13f5538f0bd1b7e2f3e6f67e29c9e8c9d0", + "reason": "no-runtime-reference" + }, + { + "projectPath": "static/check.png", + "bytes": 1475, + "sha256": "829e9714c4a521a847c0a87b01386aea25a052148a26189d77adbb7a99339657", + "reason": "no-runtime-reference" + }, + { + "projectPath": "static/user/home.png", + "bytes": 4443, + "sha256": "e5447ff7b4d03e5bf3443eb8f25ef680eccbdd42d5fa9aa50365fcb1f1f7482c", + "reason": "no-runtime-reference" + }, + { + "projectPath": "static/user/home_no.png", + "bytes": 4687, + "sha256": "e2a7dcf9f110bbb5ba1512fb50b45816cbccceb2bc154977485b37725fe74f50", + "reason": "no-runtime-reference" + }, + { + "projectPath": "static/wjw.png", + "bytes": 7833, + "sha256": "b8fd2eb650a834fe1a5e08a70b4a1f3546094747c796a0a6db59966f93684a15", + "reason": "no-runtime-reference" + }, + { + "projectPath": "static/ys.png", + "bytes": 9630, + "sha256": "e0f7d859f3631320ba1900129b33857e92d7e0b01bac8b87da98ba5d1610d819", + "reason": "no-runtime-reference" + }, + { + "projectPath": "static/yy.png", + "bytes": 11831, + "sha256": "43141965d3766b21db7ebaa066efcfc5732a1cbeb890305d93264165637103f3", + "reason": "no-runtime-reference" + }, + { + "projectPath": "static/zs.jpg", + "bytes": 46179, + "sha256": "6a6ebd0ae52a4e590f0270d86e300b9be4f738f7d24579c8468a5b9269db730a", + "reason": "no-runtime-reference" + }, + { + "projectPath": "training/static/footprint.svg", + "bytes": 741, + "sha256": "edbb2ee1ae70984bef82b7a509d03f75544b0406a3ff67cd1ffd70ace289e97a", + "reason": "no-runtime-reference" + } + ], + "sourceMediaBytes": 24052082, + "removedProjectBytes": 24145441, + "method": "moved-byte-identical-originals-outside-project-with-verified-archive", + "contentRegenerated": false, + "cloudObjectsDeleted": false, + "completedAt": "2026-09-08T09:42:11.504Z" +} diff --git a/TongjiUniApp/build/tang-detective-native-plugin.mjs b/TongjiUniApp/build/tang-detective-native-plugin.mjs new file mode 100644 index 0000000..be5859f --- /dev/null +++ b/TongjiUniApp/build/tang-detective-native-plugin.mjs @@ -0,0 +1,392 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { applyCosMediaOutput, DEFAULT_MEDIA_MANIFEST_PATH, isMediaFile, loadCosMediaManifest, SOURCE_MANIFEST_PATH } from './tang-detective-cos-media.mjs' +import { DEFAULT_PRUNE_RECEIPT_PATH, validateSourceSnapshot } from './tang-detective-source-validation.mjs' + +export const NAMESPACE = 'tang-detective' +const TEXT_EXTENSIONS = new Set(['.js', '.json', '.wxml', '.wxss', '.wxs']) +const PAGE_WINDOW_KEYS = ['navigationStyle', 'pageOrientation', 'backgroundColor', 'backgroundTextStyle'] +const STATE_FILE = '.native-import-state.json' +const BOOT_MASK_WXML = '正在读取阅读存档…' +const BOOT_MASK_WXSS = ` +.tang-boot-mask { + position: fixed; + inset: 0; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 2147483647; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: 24px; + background: #201711; + color: #f3e5bd; + font-size: 18px; + text-align: center; +} +` + +export function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex') +} + +export function listFiles(directory) { + if (!fs.existsSync(directory)) return [] + const result = [] + function visit(current, prefix = '') { + for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isSymbolicLink()) throw new Error(`Native import does not follow symbolic links: ${relative}`) + if (entry.isDirectory()) visit(path.join(current, entry.name), relative) + else if (entry.isFile()) result.push(relative) + } + } + visit(directory) + return result +} + +function assertNamespace(namespace) { + if (!/^[a-z][a-z0-9-]*$/.test(namespace)) throw new Error(`Invalid native namespace: ${namespace}`) +} + +function outputPath(directory, relative) { + if (typeof relative !== 'string' || !relative || path.isAbsolute(relative) + || relative.includes('\\') || relative.split('/').some(part => !part || part === '.' || part === '..')) { + throw new Error(`Unsafe native output path: ${relative}`) + } + return path.join(directory, relative) +} + +function lstatOrNull(filename) { + try { return fs.lstatSync(filename) } catch (error) { + if (error.code === 'ENOENT') return null + throw error + } +} + +/** Ancestors such as macOS /tmp may be aliases, but the supplied output root + * itself and every component below it must be actual directories/files. */ +export function createNativeOutputGuard(directory) { + const suppliedRoot = path.resolve(directory) + const suppliedStat = lstatOrNull(suppliedRoot) + if (suppliedStat?.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${suppliedRoot}`) + if (!suppliedStat?.isDirectory()) throw new Error(`Native output root must be an existing directory: ${suppliedRoot}`) + const root = fs.realpathSync(suppliedRoot) + function check(relative, expectedType = 'file') { + const rootStat = lstatOrNull(root) + if (rootStat?.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${root}`) + if (!rootStat?.isDirectory() || fs.realpathSync(root) !== root) throw new Error(`Native output root changed: ${root}`) + const destination = outputPath(root, relative) + const parts = relative.split('/') + let current = root + for (const [index, part] of parts.entries()) { + current = path.join(current, part) + const stat = lstatOrNull(current) + if (!stat) break + if (stat.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${current}`) + const real = fs.realpathSync(current) + const inside = path.relative(root, real) + if (path.isAbsolute(inside) || inside === '..' || inside.startsWith(`..${path.sep}`)) { + throw new Error(`Native output escaped its real directory: ${current}`) + } + const needsDirectory = index < parts.length - 1 || expectedType === 'directory' + if (needsDirectory ? !stat.isDirectory() : !stat.isFile()) { + throw new Error(`Unexpected native output path type: ${current}`) + } + } + return destination + } + function write(relative, data) { + const destination = check(relative) + fs.mkdirSync(path.dirname(destination), { recursive: true }) + check(relative) + // Re-check parent components after mkdir and prevent following a replaced + // final-file link between lstat and opening the destination. + const descriptor = fs.openSync(destination, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW) + try { fs.writeFileSync(descriptor, data) } finally { fs.closeSync(descriptor) } + } + return { root, path: check, write } +} + +function jsonBytes(value) { + return `${JSON.stringify(value, null, 2)}\n` +} + +/** Transform only the imported copy. Remote URLs, hashes, and relative requires stay intact. */ +export function transformNativeText(source, relativePath, { namespace = NAMESPACE } = {}) { + assertNamespace(namespace) + if (!TEXT_EXTENSIONS.has(path.extname(relativePath))) return source + return source + .replace(/(["'`])\/(?=(?:assets|pages|package-[a-z0-9-]+)\/)/g, `$1/${namespace}/`) + .replace(/(["'`])(?=package-(?:game|chapter-|audio-))/g, `$1${namespace}/`) + .replaceAll( + String.raw`/^\/package-[a-z0-9-]+\//i`, + String.raw`/^\/${namespace}\/package-[a-z0-9-]+\//i`, + ) +} + +export function createNativeManifest(sourceApp, { namespace = NAMESPACE } = {}) { + assertNamespace(namespace) + const sourcePackages = sourceApp.subPackages || sourceApp.subpackages || [] + const packageNames = new Map(sourcePackages.map(item => [item.name || item.root, `${namespace}-${item.name || item.root}`])) + const pages = (sourceApp.pages || []).map(item => `${namespace}/${item}`) + const subPackages = sourcePackages.map(item => ({ + ...item, + root: `${namespace}/${item.root}`, + name: packageNames.get(item.name || item.root), + pages: [...item.pages], + })) + const preloadRule = Object.fromEntries(Object.entries(sourceApp.preloadRule || {}).map(([page, rule]) => [ + `${namespace}/${page}`, + { ...rule, packages: rule.packages.map(name => { + const mapped = packageNames.get(name) + || subPackages.find(item => item.root === `${namespace}/${name}`)?.name + if (!mapped) throw new Error(`Unregistered native preload package: ${name}`) + return mapped + }) }, + ])) + const allPages = [...pages, ...subPackages.flatMap(item => item.pages.map(page => `${item.root}/${page}`))] + if (new Set(allPages).size !== allPages.length) throw new Error('Duplicate native page route') + return { namespace, pages, subPackages, preloadRule, allPages } +} + +/** Append native routes without replacing the host's app settings or route ordering. */ +export function mergeNativeAppManifest(hostApp, nativeManifest) { + const result = structuredClone(hostApp) + const packageKey = Object.hasOwn(hostApp, 'subpackages') && !Object.hasOwn(hostApp, 'subPackages') + ? 'subpackages' : 'subPackages' + const hostPages = result.pages || [] + const hostPackages = result[packageKey] || [] + const nativePages = new Set(nativeManifest.allPages) + for (const item of hostPackages) { + const proposed = nativeManifest.subPackages.find(candidate => candidate.root === item.root) + if (proposed) { + if (JSON.stringify(item) !== JSON.stringify(proposed)) throw new Error(`Native subpackage conflicts with host: ${item.root}`) + continue + } + if ((item.pages || []).some(page => nativePages.has(`${item.root}/${page}`))) { + throw new Error(`Native page conflicts with host subpackage: ${item.root}`) + } + if (nativeManifest.subPackages.some(candidate => candidate.name === item.name)) { + throw new Error(`Native subpackage name conflicts with host: ${item.name}`) + } + } + for (const page of hostPages) { + if (nativePages.has(page) && !nativeManifest.pages.includes(page)) { + throw new Error(`Native subpackage page is already a host main page: ${page}`) + } + } + result.pages = [...hostPages, ...nativeManifest.pages.filter(page => !hostPages.includes(page))] + result[packageKey] = [...hostPackages, ...nativeManifest.subPackages.filter(item => !hostPackages.some(existing => existing.root === item.root))] + const preloadRule = { ...(result.preloadRule || {}) } + for (const [page, rule] of Object.entries(nativeManifest.preloadRule)) { + if (preloadRule[page] && JSON.stringify(preloadRule[page]) !== JSON.stringify(rule)) { + throw new Error(`Native preload conflicts with host: ${page}`) + } + preloadRule[page] = rule + } + result.preloadRule = preloadRule + return result +} + +export function wrapNativePage(source, relativePath) { + const wrapperPath = path.posix.relative(path.posix.dirname(relativePath), 'utils/tangPage.js') + const topLevelCalls = [...source.matchAll(/^Page\(\{/gm)] + if (topLevelCalls.length !== 1) throw new Error(`Expected one top-level native Page registration: ${relativePath}`) + return source.replace(/^Page\(\{/m, `require(${JSON.stringify(wrapperPath)})({`) +} + +function collectOutputFiles(sourceDirectory, overlayDirectory, sourceApp, nativeManifest, apiBaseUrl, media) { + const files = new Map() + const sourcePages = new Set(nativeManifest.allPages.map(page => page.slice(nativeManifest.namespace.length + 1))) + const pageDefaults = Object.fromEntries(PAGE_WINDOW_KEYS + .filter(key => sourceApp.window?.[key] !== undefined) + .map(key => [key, sourceApp.window[key]])) + for (const relative of listFiles(sourceDirectory)) { + if (['app.js', 'app.json', 'sitemap.json'].includes(relative)) continue + const original = fs.readFileSync(outputPath(sourceDirectory, relative)) + if (relative === 'app.wxss') { + files.set('shared.wxss', original) + continue + } + const extension = path.extname(relative) + if (!TEXT_EXTENSIONS.has(extension)) { + files.set(relative, original) + continue + } + let content = transformNativeText(original.toString('utf8'), relative, nativeManifest) + const isPage = sourcePages.has(relative.slice(0, -extension.length)) + if (isPage && extension === '.json') content = jsonBytes({ ...pageDefaults, ...JSON.parse(content) }) + if (isPage && extension === '.wxss') { + content = `@import "/${nativeManifest.namespace}/shared.wxss";\n${content}` + } + files.set(relative, Buffer.from(content)) + } + // Adapters contain final output paths. Do not transform them a second time. + for (const relative of listFiles(overlayDirectory)) { + // Remote helpers are activated together with a verified manifest only. + if (relative === 'utils/cosMedia.js') continue + if (['app.js', 'app.json', 'app.wxss', 'sitemap.json', STATE_FILE].includes(relative) + || /(^|\/)project(?:\.private)?\.config\.json$/.test(relative)) { + throw new Error(`Adapter cannot replace the host app or import state: ${relative}`) + } + if (isMediaFile(relative)) { + throw new Error(`Adapter cannot replace source media: ${relative}`) + } + files.set(relative, fs.readFileSync(outputPath(overlayDirectory, relative))) + } + files.set('utils/platformConfig.js', Buffer.from(`module.exports = ${JSON.stringify({ apiBaseUrl })};\n`)) + // Media conversion also covers the adapter's final paths, without repeating + // namespace conversion or changing the host/account lifecycle adapters. + applyCosMediaOutput(files, media, nativeManifest) + if (!files.has('utils/tangPage.js')) throw new Error('Native adapter is missing utils/tangPage.js') + files.set('shared.wxss', Buffer.from(`${files.get('shared.wxss').toString('utf8')}\n${BOOT_MASK_WXSS}`)) + for (const page of sourcePages) { + for (const extension of ['.js', '.json', '.wxml', '.wxss']) { + if (!files.has(`${page}${extension}`)) throw new Error(`Native page file is missing: ${page}${extension}`) + } + const pageJson = JSON.parse(files.get(`${page}.json`).toString('utf8')) + if (pageJson.navigationStyle !== 'custom' || pageJson.pageOrientation !== 'landscape') { + throw new Error(`Adapter lost native page configuration: ${page}`) + } + files.set(`${page}.js`, Buffer.from(wrapNativePage(files.get(`${page}.js`).toString('utf8'), `${page}.js`))) + files.set(`${page}.wxml`, Buffer.from(`${files.get(`${page}.wxml`).toString('utf8')}\n${BOOT_MASK_WXML}\n`)) + } + return files +} + +export function summarizePackageSizes(files, nativeManifest) { + const result = { sourceFileBytes: 0, mainPackageBytes: 0, subPackages: {}, mediaFileCount: 0, mediaBytes: 0 } + for (const [relative, value] of files) { + const fullPath = `${nativeManifest.namespace}/${relative}` + const subpackage = nativeManifest.subPackages.find(item => fullPath.startsWith(`${item.root}/`)) + result.sourceFileBytes += value.length + if (subpackage) result.subPackages[subpackage.root] = (result.subPackages[subpackage.root] || 0) + value.length + else result.mainPackageBytes += value.length + if (isMediaFile(relative)) { + result.mediaFileCount += 1 + result.mediaBytes += value.length + } + } + return result +} + +/** Reusable by the Vite hook and by static tests; no build or server is started here. */ +export function copyNativeProgram({ sourceDirectory, outputDirectory, overlayDirectory, namespace = NAMESPACE, apiBaseUrl = '', + mediaManifest, mediaManifestPath, sourceManifestPath, pruneReceipt, pruneReceiptPath }) { + assertNamespace(namespace) + const outputGuard = createNativeOutputGuard(outputDirectory) + outputDirectory = outputGuard.root + const media = loadCosMediaManifest({ sourceDirectory, mediaManifest, mediaManifestPath, sourceManifestPath, pruneReceipt, pruneReceiptPath }) + // This preflight must finish before collecting output or touching existing + // import ownership state, including when local mode was explicitly selected. + const sourceSnapshot = media?.sourceSnapshot || validateSourceSnapshot({ sourceDirectory, sourceManifestPath }) + const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceDirectory, 'app.json'), 'utf8')) + const hostAppPath = outputGuard.path('app.json') + const hostApp = JSON.parse(fs.readFileSync(hostAppPath, 'utf8')) + const nativeManifest = createNativeManifest(sourceApp, { namespace }) + const mergedApp = mergeNativeAppManifest(hostApp, nativeManifest) + const files = collectOutputFiles(sourceDirectory, overlayDirectory, sourceApp, nativeManifest, apiBaseUrl, media) + const nativeOutputDirectory = outputGuard.path(namespace, 'directory') + const nativeTarget = relative => { + // Validate the untrusted old-state relative path before adding namespace. + const target = outputPath(nativeOutputDirectory, relative) + return outputGuard.path(path.relative(outputDirectory, target)) + } + const statePath = nativeTarget(STATE_FILE) + const previousState = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, 'utf8')) : null + if (previousState && previousState.namespace !== namespace) throw new Error('Native output ownership mismatch') + const previousFiles = new Map((previousState?.files || []).map(item => [item.path, item.sha256])) + // Refuse to overwrite files whose ownership or later edits cannot be established. + for (const [relative, value] of files) { + const destination = nativeTarget(relative) + if (!fs.existsSync(destination)) continue + const existingHash = sha256(fs.readFileSync(destination)) + if (existingHash !== previousFiles.get(relative) && existingHash !== sha256(value)) { + throw new Error(`Refusing to overwrite unowned native output: ${relative}`) + } + } + const staleFiles = [] + for (const [relative, expectedHash] of previousFiles) { + if (files.has(relative)) continue + const stalePath = nativeTarget(relative) + if (fs.existsSync(stalePath)) { + if (sha256(fs.readFileSync(stalePath)) !== expectedHash) throw new Error(`Stale native output has external edits: ${relative}`) + staleFiles.push(relative) + } + } + // Check every stale file before removing any: a conflict late in the media + // list must not leave a previously usable local build partially stripped. + for (const relative of staleFiles) fs.unlinkSync(nativeTarget(relative)) + for (const [relative, value] of files) outputGuard.write(`${namespace}/${relative}`, value) + const state = { + version: 1, + namespace, + pages: nativeManifest.allPages, + files: [...files].map(([relative, value]) => ({ path: relative, bytes: value.length, sha256: sha256(value) })), + sizes: summarizePackageSizes(files, nativeManifest), + source: { manifestSha256: sourceSnapshot.manifestSha256, originalFiles: sourceSnapshot.files.size, + presentFiles: sourceSnapshot.actual.size, omittedMediaFiles: sourceSnapshot.missingMedia.length, + ...(sourceSnapshot.prune ? { prune: sourceSnapshot.prune } : {}) }, + ...(media ? { media: { mode: 'cos', sourceManifestSha256: media.sourceManifestSha256, + manifestSha256: media.manifestSha256, sourceMediaFiles: media.entries.size, objectCount: media.objectCount } } : {}), + validationBoundary: 'Static copy and route integration only; no device, upload, content review, or release approval.', + } + outputGuard.write(`${namespace}/${STATE_FILE}`, jsonBytes(state)) + outputGuard.write('app.json', jsonBytes(mergedApp)) + return { ...state, nativeManifest } +} + +export default function tangDetectiveNativePlugin(options = {}) { + let root + let buildOutput + const sourceRelative = options.sourceDirectory || 'native/tang-detective' + const overlayRelative = options.overlayDirectory || 'native-adapter/tang-detective' + return { + name: 'tang-detective-native-pages', + enforce: 'post', + apply: () => process.env.UNI_PLATFORM === 'mp-weixin', + configResolved(config) { + root = config.root + buildOutput = path.resolve(root, config.build.outDir) + }, + buildStart() { + const sourceDirectory = path.resolve(root, sourceRelative) + const media = loadCosMediaManifest({ sourceDirectory, mediaManifest: options.mediaManifest, + mediaManifestPath: options.mediaManifestPath, sourceManifestPath: options.sourceManifestPath, + pruneReceiptPath: options.pruneReceiptPath }) + if (!media) validateSourceSnapshot({ sourceDirectory, sourceManifestPath: options.sourceManifestPath }) + this.addWatchFile(options.mediaManifestPath || DEFAULT_MEDIA_MANIFEST_PATH) + this.addWatchFile(options.sourceManifestPath || SOURCE_MANIFEST_PATH) + this.addWatchFile(options.pruneReceiptPath || DEFAULT_PRUNE_RECEIPT_PATH) + for (const directory of [path.resolve(root, sourceRelative), path.resolve(root, overlayRelative)]) { + this.addWatchFile(directory) + for (const relative of listFiles(directory)) this.addWatchFile(path.join(directory, relative)) + } + }, + // Sequential post-order runs after normal write hooks and is repeated for watch rebuilds. + writeBundle: { + order: 'post', + sequential: true, + handler(outputOptions) { + const report = copyNativeProgram({ + sourceDirectory: path.resolve(root, sourceRelative), + overlayDirectory: path.resolve(root, overlayRelative), + outputDirectory: outputOptions.dir ? path.resolve(root, outputOptions.dir) : buildOutput, + namespace: options.namespace || NAMESPACE, + apiBaseUrl: options.apiBaseUrl || '', + mediaManifest: options.mediaManifest, + mediaManifestPath: options.mediaManifestPath, + sourceManifestPath: options.sourceManifestPath, + pruneReceiptPath: options.pruneReceiptPath, + }) + this.warn(`唐侦探原生页面已合并:${report.pages.length} 页,原生文件 ${report.sizes.sourceFileBytes} bytes,其中主包新增 ${report.sizes.mainPackageBytes} bytes;此结果不代表包体积或发布验收通过。`) + }, + }, + } +} diff --git a/TongjiUniApp/build/tang-detective-native-plugin.test.mjs b/TongjiUniApp/build/tang-detective-native-plugin.test.mjs new file mode 100644 index 0000000..b4e4d40 --- /dev/null +++ b/TongjiUniApp/build/tang-detective-native-plugin.test.mjs @@ -0,0 +1,265 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import vm from 'node:vm' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import nativePlugin, { + copyNativeProgram, + createNativeManifest, + listFiles, + mergeNativeAppManifest, + sha256, + transformNativeText, + wrapNativePage, +} from './tang-detective-native-plugin.mjs' +import { createSyntheticSource } from './tang-detective-test-fixtures.mjs' +import { loadCosMediaManifest } from './tang-detective-cos-media.mjs' +import { validateSourceSnapshot } from './tang-detective-source-validation.mjs' + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const sourceDirectory = path.join(projectRoot, 'native/tang-detective') +const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceDirectory, 'app.json'), 'utf8')) +const mediaPattern = /\.(?:jpg|jpeg|png|webp|mp3|wav|aac|m4a|ogg)$/i + +function write(directory, relative, content) { + const filename = path.join(directory, relative) + fs.mkdirSync(path.dirname(filename), { recursive: true }) + fs.writeFileSync(filename, content) +} + +function fixture(t) { + const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tang-native-test-'))) + // This directory is created and exclusively owned by this individual test. + t.after(() => fs.rmSync(temporary, { recursive: true, force: true })) + const outputDirectory = path.join(temporary, 'output') + const source = createSyntheticSource(temporary) + const overlayDirectory = path.join(temporary, 'overlay') + const hostApp = { + pages: ['pages/index/index'], + subPackages: [{ root: 'tongji', pages: ['endless-game/index'] }], + window: { navigationStyle: 'default', pageOrientation: 'portrait', backgroundColor: '#ffffff' }, + tabBar: { list: [{ pagePath: 'pages/index/index', text: '首页' }] }, + permission: { 'scope.record': { desc: '通话' } }, + preloadRule: { 'pages/index/index': { network: 'wifi', packages: ['tongji'] } }, + } + write(outputDirectory, 'app.json', JSON.stringify(hostApp)) + write(outputDirectory, 'app.js', '/* host App entry must stay unchanged */') + write(outputDirectory, 'app.wxss', '/* host global styles must stay unchanged */') + write(outputDirectory, 'project.config.json', '{"description":"host project"}') + write(outputDirectory, 'pages/index/index.js', '/* host home page */') + write(outputDirectory, 'tongji/endless-game/index.js', '/* existing game */') + write(overlayDirectory, 'utils/tangPage.js', 'module.exports = function (definition) { return Page(definition) }\n') + const options = { sourceDirectory: source.sourceDirectory, sourceManifestPath: source.sourceManifestPath, + outputDirectory, overlayDirectory, apiBaseUrl: 'https://api.example.test', mediaManifest: null } + return { ...options, options, hostApp } +} + +function packageOwner(relative, manifest) { + return manifest.subPackages.find(item => relative.startsWith(`${item.root}/`))?.root || 'main' +} + +test('original snapshot keeps 473 recorded paths and requires valid remote pruning evidence for any omitted media', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, 'build/tang-detective-source-manifest.json'), 'utf8')) + assert.equal(manifest.files.length, 473) + assert.equal(manifest.files.filter(item => mediaPattern.test(item.path)).length, 204) + const media = loadCosMediaManifest({ sourceDirectory }) + const snapshot = media?.sourceSnapshot || validateSourceSnapshot({ sourceDirectory }) + assert.equal(snapshot.actual.size + snapshot.missingMedia.length, 473) + assert.equal([...snapshot.actual.keys()].filter(file => !mediaPattern.test(file)).length, 269) + assert.equal(fs.existsSync(path.join(sourceDirectory, 'app.js')), false) +}) + +test('path conversion includes dynamic roots and packaged-path regex without changing external URLs or requires', () => { + const source = [ + "const a = '/pages/share/share?x=1'", + 'const b = "/assets/share/card.jpg"', + "const c = 'package-game'", + 'const d = `package-chapter-${number}`', + 'const e = `/${packageRoot}/assets/comic/page.jpg`', + "const f = require('../../../utils/storage')", + "const remote = 'https://cdn.example.test/assets/share/card.jpg'", + String.raw`const valid = /^\/package-[a-z0-9-]+\//i`, + ].join('\n') + const result = transformNativeText(source, 'example.js') + assert.match(result, /'\/tang-detective\/pages\/share\/share\?x=1'/) + assert.match(result, /"\/tang-detective\/assets\/share\/card.jpg"/) + assert.match(result, /'tang-detective\/package-game'/) + assert.ok(result.includes('`tang-detective/package-chapter-${number}`')) + assert.ok(result.includes('`/${packageRoot}/assets/comic/page.jpg`')) + assert.ok(result.includes("require('../../../utils/storage')")) + assert.ok(result.includes("'https://cdn.example.test/assets/share/card.jpg'")) + assert.ok(result.includes(String.raw`/^\/tang-detective\/package-[a-z0-9-]+\//i`)) + assert.equal(transformNativeText(result, 'example.js'), result) + new vm.Script(result) +}) + +test('manifest merge preserves host configuration, supports both package spellings, and rejects conflicting routes', () => { + const manifest = createNativeManifest(sourceApp) + assert.equal(manifest.pages.length, 6) + assert.equal(manifest.subPackages.length, 18) + assert.equal(manifest.allPages.length, 24) + const host = { pages: ['pages/index/index'], subpackages: [{ root: 'tongji', pages: ['pages/index'] }], window: { pageOrientation: 'portrait' } } + const merged = mergeNativeAppManifest(host, manifest) + assert.deepEqual(host, { pages: ['pages/index/index'], subpackages: [{ root: 'tongji', pages: ['pages/index'] }], window: { pageOrientation: 'portrait' } }) + assert.deepEqual(merged.window, host.window) + assert.equal(merged.subPackages, undefined) + assert.deepEqual(mergeNativeAppManifest(merged, manifest), merged) + assert.deepEqual(merged.preloadRule['tang-detective/package-game/pages/chapter/chapter'].packages, ['tang-detective-audio-c01-a']) + assert.throws(() => mergeNativeAppManifest({ pages: [manifest.subPackages[0].root + '/pages/chapter/chapter'] }, manifest), /already a host main page/) + assert.throws(() => mergeNativeAppManifest({ subPackages: [{ root: manifest.subPackages[0].root, pages: ['wrong'] }] }, manifest), /conflicts with host/) +}) + +test('all native pages copy into owned output with legal relative requires and unchanged host/media bytes', t => { + const context = fixture(t) + const guardedFiles = ['app.js', 'app.wxss', 'project.config.json', 'pages/index/index.js', 'tongji/endless-game/index.js'] + const before = new Map(guardedFiles.map(file => [file, sha256(fs.readFileSync(path.join(context.outputDirectory, file)))])) + // The final adapter path must be copied literally, without a second prefix pass. + const overlay = "module.exports = { home: '/tang-detective/pages/home/home', host: '/pages/index/index' }\n" + write(context.overlayDirectory, 'utils/overlayProbe.js', overlay) + // Final safety UI is added after adapters, including a fully replaced home template. + const homeTemplate = 'adapted home' + write(context.overlayDirectory, 'pages/home/home.wxml', homeTemplate) + const report = copyNativeProgram(context.options) + assert.equal(report.pages.length, 24) + assert.equal(report.sizes.mediaFileCount, 204) + const nativeDirectory = path.join(context.outputDirectory, 'tang-detective') + assert.equal(fs.readFileSync(path.join(nativeDirectory, 'utils/overlayProbe.js'), 'utf8'), overlay) + assert.ok(fs.readFileSync(path.join(nativeDirectory, 'pages/home/home.wxml'), 'utf8').startsWith(homeTemplate)) + const sharedStyle = fs.readFileSync(path.join(nativeDirectory, 'shared.wxss'), 'utf8') + assert.equal((sharedStyle.match(/\.tang-boot-mask\s*\{/g) || []).length, 1) + const maskStyle = sharedStyle.slice(sharedStyle.indexOf('.tang-boot-mask')) + for (const declaration of ['position: fixed', 'inset: 0', 'z-index: 2147483647', 'background: #201711', 'color: #f3e5bd', 'font-size: 18px', 'align-items: center', 'justify-content: center']) { + assert.ok(maskStyle.includes(declaration), declaration) + } + const localRequire = createRequire(path.join(context.outputDirectory, 'package.cjs')) + assert.deepEqual(localRequire(path.join(nativeDirectory, 'utils/platformConfig.js')), { apiBaseUrl: context.apiBaseUrl }) + for (const file of guardedFiles) assert.equal(sha256(fs.readFileSync(path.join(context.outputDirectory, file))), before.get(file), file) + const merged = JSON.parse(fs.readFileSync(path.join(context.outputDirectory, 'app.json'), 'utf8')) + for (const key of ['window', 'tabBar', 'permission']) assert.deepEqual(merged[key], context.hostApp[key]) + assert.deepEqual(merged.preloadRule['pages/index/index'], context.hostApp.preloadRule['pages/index/index']) + for (const relative of listFiles(context.sourceDirectory).filter(file => mediaPattern.test(file))) { + assert.equal(sha256(fs.readFileSync(path.join(nativeDirectory, relative))), sha256(fs.readFileSync(path.join(context.sourceDirectory, relative))), relative) + } + for (const page of report.pages) { + const config = JSON.parse(fs.readFileSync(path.join(context.outputDirectory, `${page}.json`), 'utf8')) + assert.equal(config.navigationStyle, 'custom', page) + assert.equal(config.pageOrientation, 'landscape', page) + const style = fs.readFileSync(path.join(context.outputDirectory, `${page}.wxss`), 'utf8') + assert.ok(style.startsWith('@import "/tang-detective/shared.wxss";'), page) + const script = fs.readFileSync(path.join(context.outputDirectory, `${page}.js`), 'utf8') + assert.doesNotMatch(script, /^Page\(\{/m, page) + assert.match(script, /require\("\.\.\/(?:\.\.\/)*utils\/tangPage\.js"\)\(\{/, page) + const template = fs.readFileSync(path.join(context.outputDirectory, `${page}.wxml`), 'utf8') + assert.equal((template.match(/class="tang-boot-mask"/g) || []).length, 1, page) + assert.ok(template.trimEnd().endsWith('正在读取阅读存档…'), page) + } + let dependencyCount = 0 + for (const relative of listFiles(nativeDirectory).filter(file => file.endsWith('.js'))) { + const filename = path.join(nativeDirectory, relative) + const script = fs.readFileSync(filename, 'utf8') + new vm.Script(script, { filename: relative }) + for (const [, specifier] of script.matchAll(/require\(['"]([^'"]+)['"]\)/g)) { + assert.ok(specifier.startsWith('.'), `${relative}: native dependencies must be relative (${specifier})`) + const resolved = localRequire.resolve(path.resolve(path.dirname(filename), specifier)) + const resolvedRelative = path.relative(context.outputDirectory, resolved).split(path.sep).join('/') + assert.ok(resolvedRelative.startsWith('tang-detective/'), `${relative}: dependency escaped namespace`) + const caller = packageOwner(`tang-detective/${relative}`, report.nativeManifest) + const dependency = packageOwner(resolvedRelative, report.nativeManifest) + assert.ok(dependency === 'main' || caller === dependency, `${relative} imports a sibling subpackage: ${specifier}`) + dependencyCount += 1 + } + } + assert.ok(dependencyCount > 300) + assert.equal(fs.existsSync(path.join(nativeDirectory, 'app.js')), false) + assert.equal(fs.existsSync(path.join(nativeDirectory, 'app.json')), false) + assert.equal(fs.existsSync(path.join(nativeDirectory, 'sitemap.json')), false) + const firstState = fs.readFileSync(path.join(nativeDirectory, '.native-import-state.json'), 'utf8') + copyNativeProgram(context.options) + assert.equal(fs.readFileSync(path.join(nativeDirectory, '.native-import-state.json'), 'utf8'), firstState) + assert.equal(JSON.parse(fs.readFileSync(path.join(context.outputDirectory, 'app.json'), 'utf8')).pages.length, 7) +}) + +test('120 chapter page image selections and C01 full tracks remain in main or their own subpackage', t => { + const context = fixture(t) + const report = copyNativeProgram(context.options) + const localRequire = createRequire(path.join(context.outputDirectory, 'package.cjs')) + const root = path.join(context.outputDirectory, 'tang-detective') + const season = localRequire(path.join(root, 'data/season.js')) + const routing = localRequire(path.join(root, 'utils/chapterRoute.js')) + function assertLocalAsset(asset, owner) { + if (!asset) return + assert.ok(asset.startsWith('/tang-detective/'), asset) + const relative = asset.slice(1) + const targetOwner = packageOwner(relative, report.nativeManifest) + assert.ok(targetOwner === 'main' || targetOwner === owner, `Illegal sibling asset read: ${owner} -> ${asset}`) + assert.ok(fs.existsSync(path.join(context.outputDirectory, relative)), `Missing selected asset: ${asset}`) + } + let pages = 0 + for (let number = 1; number <= 15; number += 1) { + const packageRoot = routing.chapterPackageRoot(number) + assert.ok(report.pages.includes(routing.chapterRoute(number).split('?')[0].slice(1))) + const chapter = season.chapters.find(item => item.chapterNumber === number) + const chapterPages = localRequire(path.join(context.outputDirectory, packageRoot, 'pages/chapter/chapterPages.js')) + const modelUtils = localRequire(path.join(context.outputDirectory, packageRoot, 'utils/comicPageModel.js')) + const { releaseAssets } = localRequire(path.join(context.outputDirectory, packageRoot, 'data/releaseAssetManifest.js')) + assert.equal(modelUtils.isPackagedPath(`/${packageRoot}/assets/example.jpg`), true) + assert.equal(modelUtils.isPackagedPath('/package-game/assets/example.jpg'), false) + const model = chapterPages.buildComicPageModel(chapter, number) + assert.equal(model.pageSequence.length, 8) + for (const page of model.pageSequence) { + const [, seasonNumber, chapterNumber, pageNumber] = page.pageId.match(/^S(\d+)-C(\d+)-P(\d+)$/) + const asset = releaseAssets[`comic.s${seasonNumber}.c${chapterNumber}.p${pageNumber}`] + const image = modelUtils.buildComicImageState(page, asset, {}) + assertLocalAsset(image.src, packageRoot) + assertLocalAsset(image.fallback, packageRoot) + pages += 1 + } + } + assert.equal(pages, 120) + for (const audioRoot of ['tang-detective/package-audio-c01-a', 'tang-detective/package-audio-c01-b']) { + const tracks = localRequire(path.join(context.outputDirectory, audioRoot, 'data/audioPages.js')) + for (const track of Object.values(tracks)) { + assertLocalAsset(track.audioSrc, audioRoot) + assertLocalAsset(track.imageSrc, audioRoot) + assert.equal(track.reviewStatus, 'technical-qa-pass-human-listening-pending') + } + } +}) + +test('overlay protection and owned cleanup preserve media, host state, and external edits', t => { + const context = fixture(t) + write(context.overlayDirectory, 'utils/obsolete.js', 'module.exports = 1') + copyNativeProgram(context.options) + fs.unlinkSync(path.join(context.overlayDirectory, 'utils/obsolete.js')) + copyNativeProgram(context.options) + assert.equal(fs.existsSync(path.join(context.outputDirectory, 'tang-detective/utils/obsolete.js')), false) + write(context.outputDirectory, 'tang-detective/utils/storage.js', '/* external user edit */') + assert.throws(() => copyNativeProgram(context.options), /unowned native output/) + write(context.overlayDirectory, 'app.js', 'App({})') + assert.throws(() => copyNativeProgram(context.options), /cannot replace the host app/) + fs.unlinkSync(path.join(context.overlayDirectory, 'app.js')) + write(context.overlayDirectory, 'assets/replacement.jpg', 'not an allowed media replacement') + assert.throws(() => copyNativeProgram(context.options), /cannot replace source media/) +}) + +test('page registration rejects ambiguous input and Vite hook remains post-sequential and WeChat-only', () => { + assert.equal(wrapNativePage('Page({\n})', 'pages/home/home.js'), 'require("../../utils/tangPage.js")({\n})') + assert.throws(() => wrapNativePage('Page({})\nPage({})', 'pages/home/home.js'), /one top-level/) + const plugin = nativePlugin() + assert.equal(plugin.enforce, 'post') + assert.equal(plugin.writeBundle.order, 'post') + assert.equal(plugin.writeBundle.sequential, true) + const previous = process.env.UNI_PLATFORM + try { + process.env.UNI_PLATFORM = 'h5' + assert.equal(plugin.apply(), false) + process.env.UNI_PLATFORM = 'mp-weixin' + assert.equal(plugin.apply(), true) + } finally { + if (previous === undefined) delete process.env.UNI_PLATFORM + else process.env.UNI_PLATFORM = previous + } +}) diff --git a/TongjiUniApp/build/tang-detective-source-manifest.json b/TongjiUniApp/build/tang-detective-source-manifest.json new file mode 100644 index 0000000..d06f12c --- /dev/null +++ b/TongjiUniApp/build/tang-detective-source-manifest.json @@ -0,0 +1,2374 @@ +{ + "version": 1, + "excludedRootFiles": [ + "app.js", + "sitemap.json" + ], + "files": [ + { + "path": "app.json", + "bytes": 3135, + "sha256": "0512f794b3883e3be30f081ee4b56ebc6a9aed1996957fb8a9b410eb193fd13d" + }, + { + "path": "app.wxss", + "bytes": 3570, + "sha256": "23939ea4d2108f37ddab458c4c5552dd4a266cfeb7ef7a40b6c1594c1129133a" + }, + { + "path": "assets/characters/female-cook.jpg", + "bytes": 11958, + "sha256": "c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f" + }, + { + "path": "assets/characters/lele.jpg", + "bytes": 102681, + "sha256": "807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168" + }, + { + "path": "assets/characters/lin-xiulan.jpg", + "bytes": 107355, + "sha256": "56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361" + }, + { + "path": "assets/characters/qin-xiaoman.jpg", + "bytes": 92914, + "sha256": "22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5" + }, + { + "path": "assets/characters/qin-zhicheng.jpg", + "bytes": 116799, + "sha256": "7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2" + }, + { + "path": "assets/characters/tang-mingyuan.jpg", + "bytes": 92880, + "sha256": "5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb" + }, + { + "path": "assets/characters/tang-shouan.jpg", + "bytes": 108663, + "sha256": "4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89" + }, + { + "path": "assets/characters/xiaozhen.jpg", + "bytes": 89720, + "sha256": "d5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb" + }, + { + "path": "assets/characters/zhao-jianguo.jpg", + "bytes": 100165, + "sha256": "1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0" + }, + { + "path": "assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg", + "bytes": 97655, + "sha256": "f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0" + }, + { + "path": "assets/scenes/gui-xiang-1978.jpg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5" + }, + { + "path": "assets/scenes/gui-xiang-1995.jpg", + "bytes": 67246, + "sha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f" + }, + { + "path": "assets/scenes/gui-xiang-1998.jpg", + "bytes": 62727, + "sha256": "d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14" + }, + { + "path": "assets/scenes/gui-xiang-2001.jpg", + "bytes": 59408, + "sha256": "9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8" + }, + { + "path": "assets/scenes/gui-xiang-2003.jpg", + "bytes": 53822, + "sha256": "68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431" + }, + { + "path": "assets/scenes/gui-xiang-2008.jpg", + "bytes": 53286, + "sha256": "c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5" + }, + { + "path": "assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7" + }, + { + "path": "assets/share/guixiang-story-share-preview-v1.jpg", + "bytes": 99232, + "sha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69" + }, + { + "path": "data/cast.js", + "bytes": 2977, + "sha256": "65be0bcf6e9d2cfd8c0b70bb4e2e833e46f7d5eb15ed6942b4a7c4f9e15c1bab" + }, + { + "path": "data/chapters.js", + "bytes": 865, + "sha256": "21298bcd8358feffc6d5a36258c7c4fbdeeeaefa669aad3cf96607078230ac7c" + }, + { + "path": "data/memoryCards.js", + "bytes": 11549, + "sha256": "698f905b7e50d86d0c77ca4e72e5f943663c8ced1f79ead7151ce0aac0846e8f" + }, + { + "path": "data/productionComicPages.js", + "bytes": 72363, + "sha256": "3f235e492302a0a700ffb89f49ef925adb707cd5de85bb62b0b83bfd58d278f0" + }, + { + "path": "data/releaseInfo.js", + "bytes": 415, + "sha256": "0db29638c7f1cfd4edac4a742500e0bdf17aaf5ded2ef2896a098262d27c033b" + }, + { + "path": "data/season.js", + "bytes": 218618, + "sha256": "8f53063ee60bd288b19d71c1fd8d841633b25403f784dc57435dfe9256a92116" + }, + { + "path": "package-audio-c01-a/assets/audio/S01-C01-P01.daa8f04a2627.mp3", + "bytes": 270139, + "sha256": "daa8f04a262767109f3a0621f178e3c58f169507e8ea09fa8d583258cb713282" + }, + { + "path": "package-audio-c01-a/assets/audio/S01-C01-P02.7774981b1878.mp3", + "bytes": 396572, + "sha256": "7774981b1878f6ad882cb7a4e3f923d67f43c12c2ea068982df1cab63820d8c2" + }, + { + "path": "package-audio-c01-a/assets/audio/S01-C01-P03.0f21daa7eccb.mp3", + "bytes": 244853, + "sha256": "0f21daa7eccbaac9139317e8209570fe025fb2403ea5cc794e12586764365885" + }, + { + "path": "package-audio-c01-a/assets/audio/S01-C01-P04.ed65c367d761.mp3", + "bytes": 375047, + "sha256": "ed65c367d76125e1404d76bb86faa0629b599f6853a39e58821617f854bfa167" + }, + { + "path": "package-audio-c01-a/assets/player-art/S01-C01-P01-title-v1.jpg", + "bytes": 105278, + "sha256": "12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a" + }, + { + "path": "package-audio-c01-a/assets/player-art/S01-C01-P02-missing-table-ensemble-v1.jpg", + "bytes": 97655, + "sha256": "f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0" + }, + { + "path": "package-audio-c01-a/assets/player-art/S01-C01-P03-H01-scan-only-v1.jpg", + "bytes": 91819, + "sha256": "6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80" + }, + { + "path": "package-audio-c01-a/assets/player-art/S01-C01-P04-H02-extra-dish-v1.jpg", + "bytes": 120832, + "sha256": "bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce" + }, + { + "path": "package-audio-c01-a/data/audioPages.js", + "bytes": 2569, + "sha256": "b4d9213b408805d9b218ce60e13fa7b2c14733c1f258937a412b7dcd5784699e" + }, + { + "path": "package-audio-c01-a/pages/player/player.js", + "bytes": 20842, + "sha256": "f081d5e8b11489e5dae1a3c621b5c6416661b9d2ef68937bf094c98058b49779" + }, + { + "path": "package-audio-c01-a/pages/player/player.json", + "bytes": 28, + "sha256": "a4a6ce4b4a53e89610d78055507416398e02427786d8f702d2864b5c1e0027c0" + }, + { + "path": "package-audio-c01-a/pages/player/player.wxml", + "bytes": 2508, + "sha256": "8e8bb322083b8923527d80bb0a320e725ac156cc13cb2f13db72f7101dd00b47" + }, + { + "path": "package-audio-c01-a/pages/player/player.wxss", + "bytes": 4637, + "sha256": "d4ef6469e03922b9566ce34556d67d9534302fb8848c01e922b6c6a190fbaa83" + }, + { + "path": "package-audio-c01-b/assets/audio/S01-C01-P05.210005d24e32.mp3", + "bytes": 263661, + "sha256": "210005d24e3276e2689e38b0bb3a9d6b6e900ca3cc5ca874278a0b89a50def6a" + }, + { + "path": "package-audio-c01-b/assets/audio/S01-C01-P06.5e69ed4c17f3.mp3", + "bytes": 107344, + "sha256": "5e69ed4c17f3d5c4168546c0b76a6d5bfb13ecbecdd982c5be1c27259b9ce1a4" + }, + { + "path": "package-audio-c01-b/assets/audio/S01-C01-P07.a68bc0cb8ecc.mp3", + "bytes": 84356, + "sha256": "a68bc0cb8ecc6d53b7c877567d6c21fdd894c914e84c072357116429d05d0cc5" + }, + { + "path": "package-audio-c01-b/assets/audio/S01-C01-P08.1c44eff9d71f.mp3", + "bytes": 316951, + "sha256": "1c44eff9d71fcd004e53050e0727ee37fa3560509d3579c0e2cd004e80e246ec" + }, + { + "path": "package-audio-c01-b/assets/player-art/S01-C01-P05-H03-four-imprints-v1.jpg", + "bytes": 111834, + "sha256": "d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f" + }, + { + "path": "package-audio-c01-b/assets/player-art/S01-C01-P06-H04-unsaid-speech-v1.jpg", + "bytes": 120294, + "sha256": "7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750" + }, + { + "path": "package-audio-c01-b/assets/player-art/S01-C01-P07-EM01-call-zhao-in-v1.jpg", + "bytes": 96418, + "sha256": "4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31" + }, + { + "path": "package-audio-c01-b/assets/player-art/S01-C01-P08-cliffhanger-v1.jpg", + "bytes": 99232, + "sha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69" + }, + { + "path": "package-audio-c01-b/data/audioPages.js", + "bytes": 2510, + "sha256": "33d91f7b276fc9e8f142db056ceff912af7da63d15cefbb88a553c34b073e6a9" + }, + { + "path": "package-audio-c01-b/pages/player/player.js", + "bytes": 20842, + "sha256": "f081d5e8b11489e5dae1a3c621b5c6416661b9d2ef68937bf094c98058b49779" + }, + { + "path": "package-audio-c01-b/pages/player/player.json", + "bytes": 28, + "sha256": "a4a6ce4b4a53e89610d78055507416398e02427786d8f702d2864b5c1e0027c0" + }, + { + "path": "package-audio-c01-b/pages/player/player.wxml", + "bytes": 2508, + "sha256": "8e8bb322083b8923527d80bb0a320e725ac156cc13cb2f13db72f7101dd00b47" + }, + { + "path": "package-audio-c01-b/pages/player/player.wxss", + "bytes": 4637, + "sha256": "d4ef6469e03922b9566ce34556d67d9534302fb8848c01e922b6c6a190fbaa83" + }, + { + "path": "package-audio-player/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-audio-player/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-audio-player/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-audio-player/pages/player/player.js", + "bytes": 25953, + "sha256": "f5adb542c813a4c2c8034b25b73444283114a212c82baef1cba6f4c2c114295c" + }, + { + "path": "package-audio-player/pages/player/player.json", + "bytes": 28, + "sha256": "a4a6ce4b4a53e89610d78055507416398e02427786d8f702d2864b5c1e0027c0" + }, + { + "path": "package-audio-player/pages/player/player.wxml", + "bytes": 3197, + "sha256": "8681d94aad027b131c6a607ff4f5f638eba5066b7af57c767c619e355e3efb81" + }, + { + "path": "package-audio-player/pages/player/player.wxss", + "bytes": 5909, + "sha256": "6b38b5dff9c82aadb373edb05c1724c32179d66c7d6635f9517f3843c6bf9e62" + }, + { + "path": "package-audio-player/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-audio-player/utils/chapterRoute.js", + "bytes": 773, + "sha256": "3b5ffb45dc73328e209f2ed476502bbac58b3b1c0835d5113b39ea09d40c17e8" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS001.mp3", + "bytes": 19125, + "sha256": "580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS002-ATTR.mp3", + "bytes": 4797, + "sha256": "aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS002.mp3", + "bytes": 6417, + "sha256": "ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS003.mp3", + "bytes": 19053, + "sha256": "3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS004.mp3", + "bytes": 8505, + "sha256": "e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS005.mp3", + "bytes": 7749, + "sha256": "6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS006.mp3", + "bytes": 12465, + "sha256": "067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS007.mp3", + "bytes": 12213, + "sha256": "7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS008.mp3", + "bytes": 12465, + "sha256": "288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS009.mp3", + "bytes": 6993, + "sha256": "5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS010.mp3", + "bytes": 27189, + "sha256": "d371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS011.mp3", + "bytes": 15741, + "sha256": "2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MS012.mp3", + "bytes": 32697, + "sha256": "33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-MT000.mp3", + "bytes": 4005, + "sha256": "5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e" + }, + { + "path": "package-chapter-02/assets/audio/S01-C02-TE900.mp3", + "bytes": 7713, + "sha256": "d57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg", + "bytes": 103969, + "sha256": "c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg", + "bytes": 128391, + "sha256": "892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg", + "bytes": 110191, + "sha256": "e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg", + "bytes": 125153, + "sha256": "ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg", + "bytes": 128039, + "sha256": "cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg", + "bytes": 99846, + "sha256": "61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg", + "bytes": 133519, + "sha256": "c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539" + }, + { + "path": "package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg", + "bytes": 128463, + "sha256": "6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703" + }, + { + "path": "package-chapter-02/assets/scenes/gui-xiang-1978.jpg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5" + }, + { + "path": "package-chapter-02/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-02/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-02/data/productionComicPages.js", + "bytes": 88, + "sha256": "bea99e5e450275b719f909a79cc2d7fefb3638ac461064a3094474a18472c512" + }, + { + "path": "package-chapter-02/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-02/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-02/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-02/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-02/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-02/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-02/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-02/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-02/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-02/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-02/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS001.mp3", + "bytes": 25821, + "sha256": "b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS002-ATTR.mp3", + "bytes": 10557, + "sha256": "ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS002.mp3", + "bytes": 6489, + "sha256": "c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS003.mp3", + "bytes": 20205, + "sha256": "af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS004.mp3", + "bytes": 7749, + "sha256": "c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS005-ATTR.mp3", + "bytes": 5517, + "sha256": "4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS005.mp3", + "bytes": 10053, + "sha256": "46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS006.mp3", + "bytes": 13401, + "sha256": "2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS007-ATTR.mp3", + "bytes": 6309, + "sha256": "f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS007.mp3", + "bytes": 6849, + "sha256": "68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS008.mp3", + "bytes": 5373, + "sha256": "d9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS009.mp3", + "bytes": 20925, + "sha256": "97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS010.mp3", + "bytes": 6165, + "sha256": "49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS011.mp3", + "bytes": 15777, + "sha256": "6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS013.mp3", + "bytes": 19017, + "sha256": "8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS014.mp3", + "bytes": 4869, + "sha256": "8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MS015.mp3", + "bytes": 14193, + "sha256": "661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-MT000.mp3", + "bytes": 4077, + "sha256": "374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65" + }, + { + "path": "package-chapter-03/assets/audio/S01-C03-TE900.mp3", + "bytes": 6813, + "sha256": "f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg", + "bytes": 118129, + "sha256": "7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg", + "bytes": 194945, + "sha256": "feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg", + "bytes": 207111, + "sha256": "841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg", + "bytes": 199826, + "sha256": "8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg", + "bytes": 171496, + "sha256": "cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg", + "bytes": 84540, + "sha256": "00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg", + "bytes": 182868, + "sha256": "787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602" + }, + { + "path": "package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg", + "bytes": 210097, + "sha256": "056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5" + }, + { + "path": "package-chapter-03/assets/scenes/gui-xiang-1978.jpg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5" + }, + { + "path": "package-chapter-03/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-03/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-03/data/productionComicPages.js", + "bytes": 88, + "sha256": "bea99e5e450275b719f909a79cc2d7fefb3638ac461064a3094474a18472c512" + }, + { + "path": "package-chapter-03/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-03/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-03/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-03/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-03/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-03/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-03/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-03/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-03/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-03/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-03/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg", + "bytes": 83596, + "sha256": "e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg", + "bytes": 105099, + "sha256": "d83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg", + "bytes": 91939, + "sha256": "c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg", + "bytes": 89468, + "sha256": "70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg", + "bytes": 93259, + "sha256": "4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg", + "bytes": 92724, + "sha256": "25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg", + "bytes": 98885, + "sha256": "3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d" + }, + { + "path": "package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg", + "bytes": 104015, + "sha256": "9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61" + }, + { + "path": "package-chapter-04/assets/scenes/gui-xiang-1978.jpg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5" + }, + { + "path": "package-chapter-04/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-04/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-04/data/productionComicPages.js", + "bytes": 5478, + "sha256": "23820e31657f0624346ba82bab7279929b0725d70d382286c5a7c43b747655dd" + }, + { + "path": "package-chapter-04/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-04/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-04/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-04/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-04/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-04/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-04/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-04/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-04/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-04/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-04/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg", + "bytes": 113102, + "sha256": "52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg", + "bytes": 156680, + "sha256": "897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg", + "bytes": 147490, + "sha256": "76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg", + "bytes": 124380, + "sha256": "c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg", + "bytes": 159817, + "sha256": "a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg", + "bytes": 146026, + "sha256": "63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg", + "bytes": 150951, + "sha256": "172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567" + }, + { + "path": "package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg", + "bytes": 190776, + "sha256": "b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f" + }, + { + "path": "package-chapter-05/assets/scenes/gui-xiang-1978.jpg", + "bytes": 65192, + "sha256": "bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5" + }, + { + "path": "package-chapter-05/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-05/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-05/data/productionComicPages.js", + "bytes": 88, + "sha256": "bea99e5e450275b719f909a79cc2d7fefb3638ac461064a3094474a18472c512" + }, + { + "path": "package-chapter-05/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-05/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-05/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-05/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-05/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-05/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-05/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-05/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-05/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-05/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-05/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg", + "bytes": 190405, + "sha256": "f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg", + "bytes": 140303, + "sha256": "0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg", + "bytes": 181320, + "sha256": "caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg", + "bytes": 150118, + "sha256": "f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg", + "bytes": 154740, + "sha256": "408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg", + "bytes": 142954, + "sha256": "84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg", + "bytes": 152752, + "sha256": "c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b" + }, + { + "path": "package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg", + "bytes": 170210, + "sha256": "bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5" + }, + { + "path": "package-chapter-06/assets/scenes/gui-xiang-1995.jpg", + "bytes": 67246, + "sha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f" + }, + { + "path": "package-chapter-06/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-06/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-06/data/productionComicPages.js", + "bytes": 6133, + "sha256": "071e8140fcd61acef3a3bf799bfef6609c835ebbb43590ab7d32bd96ff5715a5" + }, + { + "path": "package-chapter-06/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-06/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-06/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-06/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-06/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-06/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-06/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-06/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-06/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-06/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-06/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg", + "bytes": 165311, + "sha256": "42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg", + "bytes": 173413, + "sha256": "81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg", + "bytes": 128377, + "sha256": "fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg", + "bytes": 147055, + "sha256": "8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg", + "bytes": 157850, + "sha256": "5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg", + "bytes": 174837, + "sha256": "7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg", + "bytes": 130849, + "sha256": "19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5" + }, + { + "path": "package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg", + "bytes": 161095, + "sha256": "f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e" + }, + { + "path": "package-chapter-07/assets/scenes/gui-xiang-1995.jpg", + "bytes": 67246, + "sha256": "a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f" + }, + { + "path": "package-chapter-07/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-07/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-07/data/productionComicPages.js", + "bytes": 6326, + "sha256": "903a0fa029b86b2bcd4f9d233064feeea09d03f97e2d1e36cc546ab046bac3a1" + }, + { + "path": "package-chapter-07/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-07/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-07/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-07/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-07/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-07/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-07/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-07/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-07/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-07/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-07/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg", + "bytes": 157367, + "sha256": "b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg", + "bytes": 191697, + "sha256": "4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg", + "bytes": 198741, + "sha256": "2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg", + "bytes": 178634, + "sha256": "737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg", + "bytes": 180813, + "sha256": "41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg", + "bytes": 163905, + "sha256": "fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg", + "bytes": 116785, + "sha256": "6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea" + }, + { + "path": "package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg", + "bytes": 138725, + "sha256": "2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743" + }, + { + "path": "package-chapter-08/assets/scenes/gui-xiang-1998.jpg", + "bytes": 62727, + "sha256": "d4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14" + }, + { + "path": "package-chapter-08/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-08/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-08/data/productionComicPages.js", + "bytes": 6525, + "sha256": "2abc3010d9a1bd916e194aef0b1a1c72dba3d47ee2c6c2f89438816304ddb952" + }, + { + "path": "package-chapter-08/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-08/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-08/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-08/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-08/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-08/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-08/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-08/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-08/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-08/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-08/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg", + "bytes": 150476, + "sha256": "59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg", + "bytes": 179019, + "sha256": "0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg", + "bytes": 192720, + "sha256": "9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg", + "bytes": 139199, + "sha256": "cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg", + "bytes": 164727, + "sha256": "5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg", + "bytes": 180695, + "sha256": "5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg", + "bytes": 176826, + "sha256": "9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f" + }, + { + "path": "package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg", + "bytes": 152317, + "sha256": "b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724" + }, + { + "path": "package-chapter-09/assets/scenes/gui-xiang-2001.jpg", + "bytes": 59408, + "sha256": "9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8" + }, + { + "path": "package-chapter-09/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-09/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-09/data/productionComicPages.js", + "bytes": 7710, + "sha256": "b7c51047d396f4ae40115c6671fd198a40b394551cc9cdb6fe8393bee6edf491" + }, + { + "path": "package-chapter-09/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-09/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-09/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-09/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-09/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-09/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-09/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-09/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-09/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-09/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-09/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg", + "bytes": 170147, + "sha256": "42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg", + "bytes": 201027, + "sha256": "30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg", + "bytes": 145630, + "sha256": "23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg", + "bytes": 142107, + "sha256": "be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg", + "bytes": 189031, + "sha256": "a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg", + "bytes": 161904, + "sha256": "6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg", + "bytes": 157096, + "sha256": "13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99" + }, + { + "path": "package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg", + "bytes": 205032, + "sha256": "2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0" + }, + { + "path": "package-chapter-10/assets/scenes/gui-xiang-2003.jpg", + "bytes": 53822, + "sha256": "68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431" + }, + { + "path": "package-chapter-10/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-10/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-10/data/productionComicPages.js", + "bytes": 6796, + "sha256": "dc39195a47156ab98293602d1037e6b6ac8398c654c88ec410d9e07df26c1313" + }, + { + "path": "package-chapter-10/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-10/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-10/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-10/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-10/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-10/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-10/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-10/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-10/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-10/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-10/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg", + "bytes": 170560, + "sha256": "840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg", + "bytes": 122506, + "sha256": "112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg", + "bytes": 95514, + "sha256": "23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg", + "bytes": 185485, + "sha256": "3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg", + "bytes": 120040, + "sha256": "c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg", + "bytes": 172410, + "sha256": "4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg", + "bytes": 157302, + "sha256": "9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a" + }, + { + "path": "package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg", + "bytes": 112557, + "sha256": "b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de" + }, + { + "path": "package-chapter-11/assets/scenes/gui-xiang-2008.jpg", + "bytes": 53286, + "sha256": "c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5" + }, + { + "path": "package-chapter-11/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-11/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-11/data/productionComicPages.js", + "bytes": 6663, + "sha256": "1c0ccd655e45288505159d253688bb60561508a864f87833b0c38ea2158b59cc" + }, + { + "path": "package-chapter-11/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-11/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-11/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-11/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-11/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-11/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-11/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-11/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-11/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-11/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-11/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg", + "bytes": 167948, + "sha256": "e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg", + "bytes": 190911, + "sha256": "6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg", + "bytes": 133362, + "sha256": "c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg", + "bytes": 152941, + "sha256": "1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg", + "bytes": 164550, + "sha256": "695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg", + "bytes": 153998, + "sha256": "9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg", + "bytes": 195056, + "sha256": "338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5" + }, + { + "path": "package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg", + "bytes": 135599, + "sha256": "a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f" + }, + { + "path": "package-chapter-12/assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7" + }, + { + "path": "package-chapter-12/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-12/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-12/data/productionComicPages.js", + "bytes": 7326, + "sha256": "4b297cb7e00bde13356e3ddbbab8839f0fd5679668dec2f0774550085888dbd0" + }, + { + "path": "package-chapter-12/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-12/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-12/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-12/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-12/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-12/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-12/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-12/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-12/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-12/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-12/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg", + "bytes": 191222, + "sha256": "2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg", + "bytes": 201951, + "sha256": "a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg", + "bytes": 201320, + "sha256": "13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg", + "bytes": 173154, + "sha256": "d4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg", + "bytes": 214867, + "sha256": "2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg", + "bytes": 184565, + "sha256": "8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg", + "bytes": 198924, + "sha256": "edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b" + }, + { + "path": "package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg", + "bytes": 179621, + "sha256": "f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc" + }, + { + "path": "package-chapter-13/assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7" + }, + { + "path": "package-chapter-13/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-13/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-13/data/productionComicPages.js", + "bytes": 6717, + "sha256": "828e335385ef32427ebcc483c1fbe4a610bb4eb3bb8ba3e3138fa400e130c33a" + }, + { + "path": "package-chapter-13/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-13/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-13/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-13/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-13/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-13/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-13/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-13/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-13/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-13/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-13/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg", + "bytes": 173212, + "sha256": "6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg", + "bytes": 204940, + "sha256": "15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg", + "bytes": 181140, + "sha256": "9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg", + "bytes": 190877, + "sha256": "899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg", + "bytes": 181882, + "sha256": "b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg", + "bytes": 192247, + "sha256": "c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg", + "bytes": 166223, + "sha256": "d0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d" + }, + { + "path": "package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg", + "bytes": 172139, + "sha256": "09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c" + }, + { + "path": "package-chapter-14/assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7" + }, + { + "path": "package-chapter-14/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-14/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-14/data/productionComicPages.js", + "bytes": 6622, + "sha256": "6d62c5acbcf69426dd48e3f98e0aea67b1e691da080053cc484546b07b2a77ab" + }, + { + "path": "package-chapter-14/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-14/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-14/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-14/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-14/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-14/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-14/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-14/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-14/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-14/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-14/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg", + "bytes": 207370, + "sha256": "194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg", + "bytes": 189454, + "sha256": "b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg", + "bytes": 162103, + "sha256": "b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg", + "bytes": 138672, + "sha256": "2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg", + "bytes": 166021, + "sha256": "21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg", + "bytes": 172654, + "sha256": "3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg", + "bytes": 210429, + "sha256": "98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98" + }, + { + "path": "package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg", + "bytes": 96215, + "sha256": "520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113" + }, + { + "path": "package-chapter-15/assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7" + }, + { + "path": "package-chapter-15/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-chapter-15/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-chapter-15/data/productionComicPages.js", + "bytes": 6926, + "sha256": "4c08ba201eb03c87ddc3f3eea1c6a69b87ee6ccae9e4184651b65b136538891e" + }, + { + "path": "package-chapter-15/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-chapter-15/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-chapter-15/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-chapter-15/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-chapter-15/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-chapter-15/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-chapter-15/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-chapter-15/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-chapter-15/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-chapter-15/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-chapter-15/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "package-game/assets/audio/S01-C01-MS007.mp3", + "bytes": 18957, + "sha256": "1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8" + }, + { + "path": "package-game/assets/audio/S01-C01-MS010.mp3", + "bytes": 17421, + "sha256": "628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597" + }, + { + "path": "package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg", + "bytes": 105278, + "sha256": "12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a" + }, + { + "path": "package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg", + "bytes": 91819, + "sha256": "6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80" + }, + { + "path": "package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg", + "bytes": 120832, + "sha256": "bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce" + }, + { + "path": "package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg", + "bytes": 111834, + "sha256": "d2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f" + }, + { + "path": "package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg", + "bytes": 120294, + "sha256": "7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750" + }, + { + "path": "package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg", + "bytes": 96418, + "sha256": "4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31" + }, + { + "path": "package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg", + "bytes": 99232, + "sha256": "7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69" + }, + { + "path": "package-game/assets/scenes/gui-xiang-2026.jpg", + "bytes": 56880, + "sha256": "55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7" + }, + { + "path": "package-game/data/assetReleaseConfig.js", + "bytes": 415, + "sha256": "4116ae171c485c7546b8aa1aeb1fa6f49133f09b965405345e916c71af5a6026" + }, + { + "path": "package-game/data/playableVisualPolicy.js", + "bytes": 7590, + "sha256": "30a24ada7e33eb2d04bdda30295919ba3e386cb51f739492c92fd3a5a75ebacb" + }, + { + "path": "package-game/data/productionComicPages.js", + "bytes": 72363, + "sha256": "3f235e492302a0a700ffb89f49ef925adb707cd5de85bb62b0b83bfd58d278f0" + }, + { + "path": "package-game/data/releaseAssetManifest.js", + "bytes": 46388, + "sha256": "05ec60424dc56481b7f4c15d4b658f40b04a67ac07dd4e8a0e06a406cf5297dc" + }, + { + "path": "package-game/data/remotePageAudioManifest.js", + "bytes": 2966, + "sha256": "a0aaf9f7b17a90c408459d664034dc19b9b9f1e9f7c5912cfd89538d6d0039f9" + }, + { + "path": "package-game/pages/chapter/chapter.js", + "bytes": 73976, + "sha256": "79bcf6c275bbde6b4b47cf6211abb2880d0b93bd941f6df04bffc34c8855df6d" + }, + { + "path": "package-game/pages/chapter/chapter.json", + "bytes": 103, + "sha256": "352a7c183b7d2cfc0144272311debd993995603d149d2316c852a3e6189a215d" + }, + { + "path": "package-game/pages/chapter/chapter.wxml", + "bytes": 46679, + "sha256": "3abaef5160ae41b29e7349c1bf334a81d083d4fb9be3edef896017ea7fa388f8" + }, + { + "path": "package-game/pages/chapter/chapter.wxss", + "bytes": 75622, + "sha256": "d1da4657b9bdd248e943dcd3e431578c8baf75f592dee77ffd1b4055e724a415" + }, + { + "path": "package-game/pages/chapter/chapterLayout.js", + "bytes": 3800, + "sha256": "786eab9bc25714948b8d389f0f956ce8c9c5b804a353fb1e3fdf090ac336f231" + }, + { + "path": "package-game/pages/chapter/chapterPages.js", + "bytes": 50340, + "sha256": "744f46065fac4d084418a2590a149382e1f072b42077d6c7fa8f30d455aa587f" + }, + { + "path": "package-game/utils/assetManager.js", + "bytes": 19204, + "sha256": "72b404e159ebca86469f9dbe822b7876e9cf9da823785676d51fd698e84fa34a" + }, + { + "path": "package-game/utils/comicPageModel.js", + "bytes": 9162, + "sha256": "0916ca685e330d9a0c56b38c1061d53615722955a6c6cac222e7532530e382e0" + }, + { + "path": "package-game/utils/comicReaderState.js", + "bytes": 12212, + "sha256": "a5cf4c5f78a311aff3285a88af6280e748500bc608587da02aaede075001a391" + }, + { + "path": "pages/cast/cast.js", + "bytes": 3899, + "sha256": "5e8be67aceb2ccc092131bb83ec166ef0749c869d1d3febb2fbd9ac97f2ab5ab" + }, + { + "path": "pages/cast/cast.json", + "bytes": 81, + "sha256": "167266925f050d73edf58df9dabd5b9bd50132feda9b09eb297d2cc7b3d2d76d" + }, + { + "path": "pages/cast/cast.wxml", + "bytes": 4391, + "sha256": "a24e5f69d3c3fd35e989aef02a2e4212d0f22d6daf0fda4765583ab46bbe3906" + }, + { + "path": "pages/cast/cast.wxss", + "bytes": 10988, + "sha256": "c76eb7cac43ecb638b5fee79a02fc17b79382ad92373d87eaf0941ea53da1708" + }, + { + "path": "pages/catalog/catalog.js", + "bytes": 2780, + "sha256": "1a38ec886f237cf0b5a25391c644b327f4a2e9bcc14129b0b3c0be41cb50236e" + }, + { + "path": "pages/catalog/catalog.json", + "bytes": 84, + "sha256": "7cfb1559feca77fdb1c4dcfee33541f23e7e01790f0068cc58bffad57785d39a" + }, + { + "path": "pages/catalog/catalog.wxml", + "bytes": 2577, + "sha256": "eeecc409aa288ecdeba2d86d17dae4c572206d06877d62f2d112a21646f0b28c" + }, + { + "path": "pages/catalog/catalog.wxss", + "bytes": 5772, + "sha256": "e108101f1b77240a0da024839d00376d244e20cb06378afd29a5341c372af17c" + }, + { + "path": "pages/home/home.js", + "bytes": 2777, + "sha256": "c2a6d6d4f9a8934b2b443109e1d885d92af7909a4309afedd15b0faeab761366" + }, + { + "path": "pages/home/home.json", + "bytes": 78, + "sha256": "7cc077cf2e3e9c63b4d76333dedc75c74c538eb53bde0cf4518183dcfbe2ffc7" + }, + { + "path": "pages/home/home.wxml", + "bytes": 2838, + "sha256": "5f4993bf76271773da356858cd55882a390bb2e3c53ebb643d0f098ee4087f21" + }, + { + "path": "pages/home/home.wxss", + "bytes": 12444, + "sha256": "1f2baf22d7d1fe1f968b1657843ab2a66bd15b947099c7da0caf14d0b1fd9b45" + }, + { + "path": "pages/home/homeLayout.js", + "bytes": 3573, + "sha256": "30049b83e8ba65185d21dcec2bb683d608511f7396dc135bc0b5d1d787cdebb6" + }, + { + "path": "pages/memories/memories.js", + "bytes": 1809, + "sha256": "f407895e01cb89bb3b4d64c8ac8bbb1e0d037619ef19b043930303b1ec13757b" + }, + { + "path": "pages/memories/memories.json", + "bytes": 59, + "sha256": "27041e9bbf576a46ddf154516f63f0cc101c2c02ce6853781fae61a52fc9cd16" + }, + { + "path": "pages/memories/memories.wxml", + "bytes": 4009, + "sha256": "7a5329563d46ff310dc423dedaa231648e809ab8215a3d62d18abfcef5d002a7" + }, + { + "path": "pages/memories/memories.wxss", + "bytes": 9477, + "sha256": "328792fa9a335fd3dd2a4024919876fad2a0b421eaa9d5deb8a7f7203de15813" + }, + { + "path": "pages/report/report.js", + "bytes": 2111, + "sha256": "96b0ea05d71f4f8ee91257077a0e71aed411a75c244ff59607082b842b63a91a" + }, + { + "path": "pages/report/report.json", + "bytes": 59, + "sha256": "27041e9bbf576a46ddf154516f63f0cc101c2c02ce6853781fae61a52fc9cd16" + }, + { + "path": "pages/report/report.wxml", + "bytes": 3447, + "sha256": "3a264231b5f61522c94325c2dc81fc77ea090b53cc46e9a5b4a6d008ac692f47" + }, + { + "path": "pages/report/report.wxss", + "bytes": 4096, + "sha256": "2b8d805b37630257c2aacbd84291c92a2822846bf58e12a852bf77c449be8c6f" + }, + { + "path": "pages/share/share.js", + "bytes": 3043, + "sha256": "60673512d2f4b9ffe8d5df57c313c8e9a4b99866f2a2cc3b743c2a944deb9a73" + }, + { + "path": "pages/share/share.json", + "bytes": 59, + "sha256": "27041e9bbf576a46ddf154516f63f0cc101c2c02ce6853781fae61a52fc9cd16" + }, + { + "path": "pages/share/share.wxml", + "bytes": 2785, + "sha256": "0adab4715f1065d13d3053b0f96e7deb0bff3c0970cbeceaa401729ccc3ca15c" + }, + { + "path": "pages/share/share.wxss", + "bytes": 5355, + "sha256": "9ad0e375f8d440707b123b8081f501a1784834e41d4dd3ff53da2bbf5c9f84ee" + }, + { + "path": "utils/chapterProgress.js", + "bytes": 4803, + "sha256": "d00f3ab136e1cb423aac2df192ccb55e1b6e431c4bd1ecfc038ac2a8b8ecde39" + }, + { + "path": "utils/chapterRoute.js", + "bytes": 773, + "sha256": "3b5ffb45dc73328e209f2ed476502bbac58b3b1c0835d5113b39ea09d40c17e8" + }, + { + "path": "utils/comicLayout.js", + "bytes": 2726, + "sha256": "ad07505047a1d3e65fa0518ffe87c5c148c23e5e7cc54970ba10a0fc6c6b7b06" + }, + { + "path": "utils/layout.js", + "bytes": 5591, + "sha256": "546ec87b14aef18b1deb3fc5dc148c827da6c9248bbd59bc0576dfd5df3cafe9" + }, + { + "path": "utils/memoryCollection.js", + "bytes": 2505, + "sha256": "1c1296f14853b5b1ae107807347ffcbbe31a1feea416f37416c9bfa944b8e07e" + }, + { + "path": "utils/storage.js", + "bytes": 3587, + "sha256": "9d7750ba6a244e7e9420cf2571833786d76c94d8f8d78611efe965f18b55f403" + }, + { + "path": "utils/updateManager.js", + "bytes": 1509, + "sha256": "37f68e58b5c3b950ddd209d39359b93d11da89f14944c60d2710ba0e9de781e7" + } + ] +} diff --git a/TongjiUniApp/build/tang-detective-source-validation.mjs b/TongjiUniApp/build/tang-detective-source-validation.mjs new file mode 100644 index 0000000..4bc5172 --- /dev/null +++ b/TongjiUniApp/build/tang-detective-source-validation.mjs @@ -0,0 +1,139 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { fileURLToPath } from 'node:url' + +const buildDirectory = path.dirname(fileURLToPath(import.meta.url)) +export const SOURCE_MANIFEST_PATH = path.join(buildDirectory, 'tang-detective-source-manifest.json') +export const DEFAULT_PRUNE_RECEIPT_PATH = path.join(buildDirectory, 'tang-detective-media-prune-receipt.json') +export const ORIGINAL_SOURCE_MANIFEST_SHA256 = '3b203406a31fdbe17c770aeea0bab19755acfedf3fa1595641ee4e15a9712e20' +export const MEDIA_TYPES = { + '.jpg': ['image', 'image/jpeg'], '.jpeg': ['image', 'image/jpeg'], + '.png': ['image', 'image/png'], '.webp': ['image', 'image/webp'], + '.gif': ['image', 'image/gif'], '.svg': ['image', 'image/svg+xml'], '.avif': ['image', 'image/avif'], + '.mp3': ['audio', 'audio/mpeg'], '.wav': ['audio', 'audio/wav'], + '.aac': ['audio', 'audio/aac'], '.m4a': ['audio', 'audio/mp4'], '.ogg': ['audio', 'audio/ogg'], + '.mp4': ['video', 'video/mp4'], '.webm': ['video', 'video/webm'], '.mov': ['video', 'video/quicktime'], +} +export const isMediaFile = value => Boolean(MEDIA_TYPES[path.extname(value).toLowerCase()]) +export const sha256 = value => crypto.createHash('sha256').update(value).digest('hex') +export const canonicalJsonSha256 = value => sha256(`${JSON.stringify(value, null, 2)}\n`) +const SHA256 = /^[a-f0-9]{64}$/ +function assert(condition, message) { + if (!condition) throw new Error(`Invalid Tang Detective source snapshot: ${message}`) +} +export function safeSourcePath(value) { + return typeof value === 'string' && value.length > 0 && !path.isAbsolute(value) + && !/[\\?#\u0000-\u0020]/.test(value) + && value.split('/').every(part => part && part !== '.' && part !== '..') +} +function lstatOrNull(filename) { + try { return fs.lstatSync(filename) } catch (error) { + if (error.code === 'ENOENT') return null + throw error + } +} +export function readSourceManifest(sourceManifestPath = SOURCE_MANIFEST_PATH) { + const stat = lstatOrNull(sourceManifestPath) + assert(stat?.isFile() && !stat.isSymbolicLink(), 'source manifest must be a regular file') + const bytes = fs.readFileSync(sourceManifestPath) + const manifestSha256 = sha256(bytes) + if (path.resolve(sourceManifestPath) === SOURCE_MANIFEST_PATH) { + assert(manifestSha256 === ORIGINAL_SOURCE_MANIFEST_SHA256, 'original source manifest changed') + } + const manifest = JSON.parse(bytes) + assert(Array.isArray(manifest.files) && manifest.files.length > 0, 'source manifest files are required') + const files = new Map() + for (const record of manifest.files) { + assert(record && safeSourcePath(record.path) && !files.has(record.path), 'unsafe or duplicate source manifest path') + assert(Number.isSafeInteger(record.bytes) && record.bytes >= 0 && SHA256.test(record.sha256), `invalid source metadata: ${record.path}`) + files.set(record.path, record) + } + return { manifest, manifestSha256, files } +} + +/** Inspect the entire tree, including dangling links and unexpected file types. + * No media omission may hide an extra file, source edit or symlink. */ +export function inspectSourceFiles(sourceDirectory, source = readSourceManifest()) { + const root = path.resolve(sourceDirectory) + const rootStat = lstatOrNull(root) + assert(rootStat?.isDirectory() && !rootStat.isSymbolicLink(), 'source root must be a real directory, not a symbolic link') + const actual = new Map() + const expectedDirectories = new Set([...source.files.keys()].flatMap(relative => { + const parts = relative.split('/') + return parts.slice(0, -1).map((_, index) => parts.slice(0, index + 1).join('/')) + })) + function visit(directory, prefix = '') { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name + const filename = path.join(directory, entry.name) + assert(!entry.isSymbolicLink(), `source symbolic links are forbidden: ${relative}`) + if (entry.isDirectory()) { + assert(expectedDirectories.has(relative), `unexpected source directory: ${relative}`) + visit(filename, relative) + continue + } + assert(entry.isFile(), `unexpected source file type: ${relative}`) + assert(source.files.has(relative), `unexpected source file: ${relative}`) + const record = source.files.get(relative) + const bytes = fs.readFileSync(filename) + assert(bytes.length === record.bytes && sha256(bytes) === record.sha256, `source bytes changed: ${relative}`) + actual.set(relative, record) + } + } + visit(root) + const missingMedia = [] + for (const record of source.files.values()) { + if (actual.has(record.path)) continue + assert(isMediaFile(record.path), `missing non-media source: ${record.path}`) + missingMedia.push(record.path) + } + return { actual, missingMedia } +} + +export function validatePruneReceipt(receipt, { source, media }) { + assert(receipt?.schemaVersion === 1 && receipt.status === 'completed', 'completed project-local media prune receipt is required') + assert(receipt.sourceManifestSha256 === source.manifestSha256, 'prune receipt source manifest hash mismatch') + assert(receipt.mediaManifestSha256 === media.manifestSha256, 'prune receipt media manifest hash mismatch') + assert(typeof media.uploadRunId === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(media.uploadRunId) + && receipt.uploadRunId === media.uploadRunId, 'prune receipt upload run mismatch') + assert(receipt.backup?.format === 'tar.gz' && SHA256.test(receipt.backup.sha256) + && Number.isSafeInteger(receipt.backup.bytes) && receipt.backup.bytes > 0, 'prune receipt verified backup hash and size are required') + const expectedMedia = [...source.files.values()].filter(record => isMediaFile(record.path)) + assert(media.entries instanceof Map && media.entries.size === expectedMedia.length + && expectedMedia.every(record => { + const entry = media.entries.get(record.path) + return entry?.sourcePath === record.path && entry.sha256 === record.sha256 && entry.bytes === record.bytes + && entry.uploaded === true && entry.publicReadVerified === true && entry.remoteVerifiedSha256 === record.sha256 + && (MEDIA_TYPES[path.extname(record.path)][0] === 'image' || entry.rangeVerified === true) + }), 'prune receipt requires complete verified source media coverage') + assert(Array.isArray(receipt.entries) && receipt.entries.length === expectedMedia.length, 'prune receipt media coverage is incomplete') + const seen = new Set() + for (const entry of receipt.entries) { + assert(entry && safeSourcePath(entry.sourcePath) && !seen.has(entry.sourcePath), 'unsafe or duplicate prune receipt entry') + const expected = media.entries.get(entry.sourcePath) + assert(expected && entry.sha256 === expected.sha256 && entry.bytes === expected.bytes + && entry.url === expected.url && entry.objectKey === expected.objectKey + && entry.backupSha256 === receipt.backup.sha256, `prune receipt entry mismatch: ${entry.sourcePath}`) + seen.add(entry.sourcePath) + } + return { receiptSha256: canonicalJsonSha256(receipt), uploadRunId: receipt.uploadRunId, backupSha256: receipt.backup.sha256 } +} + +export function validateSourceSnapshot({ sourceDirectory, sourceManifestPath = SOURCE_MANIFEST_PATH, + media = null, pruneReceipt, pruneReceiptPath = DEFAULT_PRUNE_RECEIPT_PATH } = {}) { + const source = readSourceManifest(sourceManifestPath) + const inspection = inspectSourceFiles(sourceDirectory, source) + let prune = null + if (inspection.missingMedia.length) { + assert(media, `local mode requires all media; ${inspection.missingMedia.length} source media files are missing`) + assert(media.sourceManifestSha256 === source.manifestSha256, 'remote source manifest binding mismatch') + if (pruneReceipt === undefined) { + const stat = lstatOrNull(pruneReceiptPath) + assert(stat?.isFile() && !stat.isSymbolicLink(), 'completed project-local media prune receipt is required') + pruneReceipt = JSON.parse(fs.readFileSync(pruneReceiptPath, 'utf8')) + } + prune = validatePruneReceipt(pruneReceipt, { source, media }) + } + return { ...source, ...inspection, prune } +} diff --git a/TongjiUniApp/build/tang-detective-source-validation.test.mjs b/TongjiUniApp/build/tang-detective-source-validation.test.mjs new file mode 100644 index 0000000..2801f47 --- /dev/null +++ b/TongjiUniApp/build/tang-detective-source-validation.test.mjs @@ -0,0 +1,184 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import nativePlugin, { copyNativeProgram, listFiles, sha256 } from './tang-detective-native-plugin.mjs' +import { validateCosMediaManifest } from './tang-detective-cos-media.mjs' +import { validateNativeOutput } from './validate-tang-detective-output.mjs' +import { readSourceManifest, validateSourceSnapshot } from './tang-detective-source-validation.mjs' +import { createSyntheticSource, fakeVerifiedManifest, fakePruneReceipt, writeFixture } from './tang-detective-test-fixtures.mjs' + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +function fixture(t, { prune = false } = {}) { + const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tang-source-test-'))) + t.after(() => fs.rmSync(temporary, { recursive: true, force: true })) + const source = createSyntheticSource(temporary) + const mediaManifest = fakeVerifiedManifest(source) + const pruneReceipt = fakePruneReceipt(mediaManifest) + const pruneReceiptPath = path.join(temporary, 'test-only-prune-receipt.json') + writeFixture(temporary, 'test-only-prune-receipt.json', JSON.stringify(pruneReceipt)) + if (prune) for (const file of source.sourceMedia) fs.unlinkSync(path.join(source.sourceDirectory, file.path)) + const outputDirectory = path.join(temporary, 'output') + writeFixture(outputDirectory, 'app.json', JSON.stringify({ pages: ['pages/index/index'] })) + writeFixture(outputDirectory, 'app.js', '/* host unchanged */') + const options = { sourceDirectory: source.sourceDirectory, sourceManifestPath: source.sourceManifestPath, + overlayDirectory: path.join(projectRoot, 'native-adapter/tang-detective'), outputDirectory, + mediaManifest, pruneReceiptPath } + return { temporary, ...source, options, mediaManifest, pruneReceipt, pruneReceiptPath, outputDirectory } +} +const snapshot = directory => listFiles(directory).map(file => [file, sha256(fs.readFileSync(path.join(directory, file)))]) + +test('the pinned original manifest has 269 nonmedia and exactly 160 JPG / 44 MP3 files', () => { + const source = readSourceManifest() + assert.equal(source.files.size, 473) + assert.equal([...source.files.keys()].filter(file => file.endsWith('.jpg')).length, 160) + assert.equal([...source.files.keys()].filter(file => file.endsWith('.mp3')).length, 44) +}) + +test('a fully pruned clone builds and validates from 269 intact files with no external backup access', t => { + const context = fixture(t, { prune: true }) + // A portable receipt records the archive hash. It never requires opening a + // prior machine's absolute path, and no archive exists in this offline test. + context.pruneReceipt.backup.originalPath = '/unavailable-on-another-machine/test-only.tar.gz' + writeFixture(context.temporary, 'test-only-prune-receipt.json', JSON.stringify(context.pruneReceipt)) + const media = validateCosMediaManifest(context.mediaManifest, context.options) + assert.equal(media.sourceSnapshot.actual.size, 269) + assert.equal(media.sourceSnapshot.missingMedia.length, 204) + assert.equal(media.sourceSnapshot.prune.backupSha256, context.pruneReceipt.backup.sha256) + const report = copyNativeProgram(context.options) + assert.equal(report.source.omittedMediaFiles, 204) + assert.equal(report.source.presentFiles, 269) + assert.equal(report.sizes.mediaFileCount, 0) + const validation = validateNativeOutput(context.outputDirectory, context.options) + assert.deepEqual(validation.errors, []) + assert.equal(validation.passed, true) + const before = snapshot(context.outputDirectory) + copyNativeProgram(context.options) + assert.deepEqual(snapshot(context.outputDirectory), before) +}) + +test('missing local media and missing remote prune receipt fail before any output changes', t => { + const context = fixture(t) + copyNativeProgram({ ...context.options, mediaManifest: null }) + const before = snapshot(context.outputDirectory) + fs.unlinkSync(path.join(context.sourceDirectory, context.sourceMedia[0].path)) + assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: null }), /local mode requires all media/) + assert.deepEqual(snapshot(context.outputDirectory), before) + fs.unlinkSync(context.pruneReceiptPath) + assert.throws(() => copyNativeProgram(context.options), /completed project-local media prune receipt is required/) + assert.deepEqual(snapshot(context.outputDirectory), before) + const report = validateNativeOutput(context.outputDirectory, { ...context.options, mediaManifest: null }) + assert.equal(report.passed, false) + assert.ok(report.errors.some(error => error.includes('local mode requires all media'))) +}) + +test('the Vite buildStart preflight rejects missing local media before bundle output can begin', t => { + const context = fixture(t, { prune: true }) + const plugin = nativePlugin({ ...context.options, mediaManifest: null }) + plugin.configResolved({ root: context.temporary, build: { outDir: 'output' } }) + const before = snapshot(context.outputDirectory) + assert.throws(() => plugin.buildStart.call({ addWatchFile() {} }), /local mode requires all media/) + assert.deepEqual(snapshot(context.outputDirectory), before) +}) + +test('pruning needs an exact complete receipt bound to the manifest, upload run, file records and archive hash', t => { + const context = fixture(t, { prune: true }) + const mutations = [ + receipt => { receipt.schemaVersion = 2 }, + receipt => { receipt.status = 'planned' }, + receipt => { receipt.sourceManifestSha256 = '0'.repeat(64) }, + receipt => { receipt.mediaManifestSha256 = '0'.repeat(64) }, + receipt => { receipt.uploadRunId = 'different-run' }, + receipt => { receipt.backup.sha256 = 'not-a-hash' }, + receipt => { receipt.backup.bytes = 0 }, + receipt => { receipt.backup.format = 'zip' }, + receipt => { receipt.entries.pop() }, + receipt => { receipt.entries[1] = { ...receipt.entries[0] } }, + receipt => { receipt.entries[0].sourcePath = '../escape.jpg' }, + receipt => { receipt.entries[0].sourcePath = 'app.json' }, + receipt => { receipt.entries[0].sha256 = '0'.repeat(64) }, + receipt => { receipt.entries[0].bytes++ }, + receipt => { receipt.entries[0].url += '?tampered' }, + receipt => { receipt.entries[0].objectKey = 'wrong.jpg' }, + receipt => { receipt.entries[0].backupSha256 = '0'.repeat(64) }, + ] + const before = snapshot(context.outputDirectory) + for (const mutate of mutations) { + const receipt = structuredClone(context.pruneReceipt) + mutate(receipt) + assert.throws(() => copyNativeProgram({ ...context.options, pruneReceipt: receipt }), /Invalid Tang Detective source snapshot/) + assert.deepEqual(snapshot(context.outputDirectory), before) + } + // Even a single omitted file needs evidence for the entire upload inventory. + const partial = fixture(t) + fs.unlinkSync(path.join(partial.sourceDirectory, partial.sourceMedia[0].path)) + assert.equal(validateCosMediaManifest(partial.mediaManifest, partial.options).sourceSnapshot.missingMedia.length, 1) + const invalidManifest = { ...context.mediaManifest, uploadRunId: undefined } + assert.throws(() => validateCosMediaManifest(invalidManifest, { + ...context.options, pruneReceipt: fakePruneReceipt(invalidManifest), + }), /upload run mismatch/) +}) + +test('remote pruning never allows missing nonmedia, changed surviving bytes or extra source files', t => { + const context = fixture(t, { prune: true }) + const nonmedia = 'utils/storage.js' + const original = fs.readFileSync(path.join(context.sourceDirectory, nonmedia)) + fs.unlinkSync(path.join(context.sourceDirectory, nonmedia)) + assert.throws(() => copyNativeProgram(context.options), /missing non-media source: utils\/storage.js/) + writeFixture(context.sourceDirectory, nonmedia, 'changed code') + assert.throws(() => copyNativeProgram(context.options), /source bytes changed: utils\/storage.js/) + writeFixture(context.sourceDirectory, nonmedia, original) + for (const relative of ['assets/unexpected.jpg', 'package-game/data/unexpected.js']) { + writeFixture(context.sourceDirectory, relative, 'unexpected') + assert.throws(() => copyNativeProgram(context.options), /unexpected source file/) + fs.unlinkSync(path.join(context.sourceDirectory, relative)) + } + fs.mkdirSync(path.join(context.sourceDirectory, 'unexpected-empty-directory')) + assert.throws(() => copyNativeProgram(context.options), /unexpected source directory/) + fs.rmdirSync(path.join(context.sourceDirectory, 'unexpected-empty-directory')) + const sourceMedia = context.sourceMedia[0] + writeFixture(context.sourceDirectory, sourceMedia.path, 'changed surviving media') + assert.throws(() => copyNativeProgram(context.options), /source bytes changed/) + assert.equal(listFiles(context.outputDirectory).length, 2) +}) + +test('source and evidence symlinks, including dangling media links, cannot authorize or hide omission', t => { + const context = fixture(t, { prune: true }) + const target = path.join(context.sourceDirectory, context.sourceMedia[0].path) + fs.symlinkSync(path.join(context.temporary, 'missing-media.jpg'), target) + assert.throws(() => copyNativeProgram(context.options), /source symbolic links are forbidden/) + fs.unlinkSync(target) + const outside = path.join(context.temporary, 'original-source-directory') + fs.renameSync(context.sourceDirectory, outside) + fs.symlinkSync(outside, context.sourceDirectory, 'dir') + try { assert.throws(() => copyNativeProgram(context.options), /source root must be a real directory/) } + finally { fs.unlinkSync(context.sourceDirectory); fs.renameSync(outside, context.sourceDirectory) } + const savedReceipt = path.join(context.temporary, 'original-prune-receipt.json') + fs.renameSync(context.pruneReceiptPath, savedReceipt) + fs.symlinkSync(savedReceipt, context.pruneReceiptPath) + assert.throws(() => copyNativeProgram(context.options), /completed project-local media prune receipt is required/) + fs.unlinkSync(context.pruneReceiptPath) + fs.renameSync(savedReceipt, context.pruneReceiptPath) + const savedSource = path.join(context.temporary, 'original-source-manifest.json') + fs.renameSync(context.sourceManifestPath, savedSource) + fs.symlinkSync(savedSource, context.sourceManifestPath) + assert.throws(() => validateSourceSnapshot(context.options), /source manifest must be a regular file/) +}) + +test('remote output validation rejects later receipt drift or removal', t => { + const context = fixture(t, { prune: true }) + copyNativeProgram(context.options) + const invalid = { ...context.pruneReceipt, uploadRunId: 'changed-run' } + const failed = validateNativeOutput(context.outputDirectory, { ...context.options, pruneReceipt: invalid }) + assert.equal(failed.passed, false) + assert.ok(failed.errors.some(error => error.includes('upload run mismatch'))) + const rebound = structuredClone(context.pruneReceipt) + rebound.backup.sha256 = '1'.repeat(64) + for (const entry of rebound.entries) entry.backupSha256 = rebound.backup.sha256 + const changedReceipt = validateNativeOutput(context.outputDirectory, { ...context.options, pruneReceipt: rebound }) + assert.ok(changedReceipt.errors.includes('Media prune receipt changed after import')) + fs.unlinkSync(context.pruneReceiptPath) + assert.equal(validateNativeOutput(context.outputDirectory, context.options).passed, false) +}) diff --git a/TongjiUniApp/build/tang-detective-test-fixtures.mjs b/TongjiUniApp/build/tang-detective-test-fixtures.mjs new file mode 100644 index 0000000..73bdef2 --- /dev/null +++ b/TongjiUniApp/build/tang-detective-test-fixtures.mjs @@ -0,0 +1,63 @@ +// Test-only synthetic transport bytes. Never use this module for a real +// activation manifest, upload evidence, prune receipt or source backup. +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { isMediaFile, readSourceManifest, sha256 } from './tang-detective-source-validation.mjs' + +const originalSourceDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../native/tang-detective') +export function writeFixture(directory, relative, bytes) { + const filename = path.join(directory, relative) + fs.mkdirSync(path.dirname(filename), { recursive: true }) + fs.writeFileSync(filename, bytes) +} +export function createSyntheticSource(temporary) { + const sourceDirectory = path.join(temporary, 'source') + const original = readSourceManifest() + const replacements = new Map([...original.files.values()].filter(file => isMediaFile(file.path)) + .map(file => [file.sha256, Buffer.from(`Test-only media fixture for ${file.sha256}\n`)])) + const hashReplacements = new Map([...replacements].map(([originalHash, bytes]) => [originalHash, sha256(bytes)])) + const files = [] + for (const file of original.files.values()) { + let bytes + if (isMediaFile(file.path)) bytes = replacements.get(file.sha256) + else { + const source = fs.readFileSync(path.join(originalSourceDirectory, file.path)) + if (source.length !== file.bytes || sha256(source) !== file.sha256) throw new Error(`Original non-media source drift: ${file.path}`) + bytes = Buffer.from(source.toString('utf8').replace(/[a-f0-9]{64}/g, value => hashReplacements.get(value) || value)) + } + writeFixture(sourceDirectory, file.path, bytes) + files.push({ path: file.path, bytes: bytes.length, sha256: sha256(bytes) }) + } + const sourceManifest = { version: 1, files } + const sourceBytes = Buffer.from(`${JSON.stringify(sourceManifest, null, 2)}\n`) + const sourceManifestPath = path.join(temporary, 'synthetic-source-manifest.json') + fs.writeFileSync(sourceManifestPath, sourceBytes) + return { sourceDirectory, sourceManifest, sourceManifestPath, sourceBytes, + sourceMedia: files.filter(file => isMediaFile(file.path)), hashReplacements } +} + +export function fakeVerifiedManifest({ sourceBytes, sourceMedia }) { + const baseUrl = 'https://tang-assets.example.test' + return { + schemaVersion: 1, uploadRunId: 'test-only-offline-run', sourceManifestSha256: sha256(sourceBytes), + destination: { bucket: 'test-bucket', region: 'test-region', baseUrl }, + entries: sourceMedia.map(file => { + const kind = file.path.endsWith('.jpg') ? 'image' : 'audio' + const objectKey = `tang-detective/season-01/media-v1/${file.sha256}${path.extname(file.path)}` + return { sourcePath: file.path, kind, contentType: kind === 'image' ? 'image/jpeg' : 'audio/mpeg', + bytes: file.bytes, sha256: file.sha256, objectKey, url: `${baseUrl}/${objectKey}`, + uploaded: true, publicReadVerified: true, remoteVerifiedSha256: file.sha256, + ...(kind === 'audio' ? { rangeVerified: true } : {}) } + }), + } +} + +export function fakePruneReceipt(mediaManifest) { + const backup = { sha256: sha256('Test-only archive receipt; no real archive or upload'), bytes: 123, format: 'tar.gz' } + return { schemaVersion: 1, status: 'completed', sourceManifestSha256: mediaManifest.sourceManifestSha256, + mediaManifestSha256: sha256(`${JSON.stringify(mediaManifest, null, 2)}\n`), uploadRunId: mediaManifest.uploadRunId, backup, + entries: mediaManifest.entries.map(({ sourcePath, sha256: digest, bytes, url, objectKey }) => ({ + sourcePath, sha256: digest, bytes, url, objectKey, backupSha256: backup.sha256, + })) } +} diff --git a/TongjiUniApp/build/validate-tang-detective-output.mjs b/TongjiUniApp/build/validate-tang-detective-output.mjs new file mode 100644 index 0000000..8c26dce --- /dev/null +++ b/TongjiUniApp/build/validate-tang-detective-output.mjs @@ -0,0 +1,118 @@ +import fs from 'node:fs' +import path from 'node:path' +import vm from 'node:vm' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { createNativeManifest, createNativeOutputGuard, listFiles, sha256 } from './tang-detective-native-plugin.mjs' +import { isMediaFile, loadCosMediaManifest } from './tang-detective-cos-media.mjs' +import { readSourceManifest, validateSourceSnapshot } from './tang-detective-source-validation.mjs' + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +export function validateNativeOutput(outputDirectory, { mediaManifest, mediaManifestPath, + sourceDirectory = path.join(projectRoot, 'native/tang-detective'), sourceManifestPath, pruneReceipt, pruneReceiptPath } = {}) { + const outputGuard = createNativeOutputGuard(outputDirectory) + const output = outputGuard.root + const nativeRoot = outputGuard.path('tang-detective', 'directory') + const sourceRoot = sourceDirectory + const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'app.json'), 'utf8')) + const manifest = createNativeManifest(sourceApp) + const hostApp = JSON.parse(fs.readFileSync(outputGuard.path('app.json'), 'utf8')) + const imported = JSON.parse(fs.readFileSync(outputGuard.path('tang-detective/.native-import-state.json'), 'utf8')) + const sourceManifest = readSourceManifest(sourceManifestPath).manifest + const localRequire = createRequire(path.join(output, 'native-validation.cjs')) + const errors = [] + const check = (condition, message) => { if (!condition) errors.push(message) } + const remoteMode = imported.media?.mode === 'cos' + let media = null + if (remoteMode) { + try { + media = loadCosMediaManifest({ sourceDirectory: sourceRoot, mediaManifest, mediaManifestPath, + sourceManifestPath, pruneReceipt, pruneReceiptPath }) + check(Boolean(media), 'COS output requires its verified media manifest') + if (media) { + check(imported.media.manifestSha256 === media.manifestSha256, 'COS manifest changed after import') + check(imported.media.sourceManifestSha256 === media.sourceManifestSha256, 'COS source manifest changed after import') + if (imported.source?.prune) { + check(imported.source.prune.receiptSha256 === media.sourceSnapshot.prune?.receiptSha256, 'Media prune receipt changed after import') + } + } + } catch (error) { errors.push(error.message) } + } else { + try { validateSourceSnapshot({ sourceDirectory: sourceRoot, sourceManifestPath }) } + catch (error) { errors.push(error.message) } + } + const allRoutes = [...hostApp.pages, ...(hostApp.subPackages || hostApp.subpackages || []).flatMap(item => item.pages.map(page => `${item.root}/${page}`))] + const packageOwner = relative => manifest.subPackages.find(item => relative.startsWith(`${item.root}/`))?.root || 'main' + let mediaFiles = 0 + for (const file of sourceManifest.files) { + if (!isMediaFile(file.path)) continue + mediaFiles += 1 + outputGuard.path(`tang-detective/${file.path}`) + if (remoteMode) { + check(!fs.existsSync(path.join(nativeRoot, file.path)), `Packaged media remains in COS mode: ${file.path}`) + check(media?.entries.get(file.path)?.sha256 === file.sha256, `Remote media mapping changed: ${file.path}`) + } else { + check(sha256(fs.readFileSync(path.join(nativeRoot, file.path))) === file.sha256, `Media changed: ${file.path}`) + } + } + for (const file of imported.files) { + check(sha256(fs.readFileSync(outputGuard.path(`tang-detective/${file.path}`))) === file.sha256, `Native output changed after import: ${file.path}`) + } + const packagedMediaFiles = listFiles(nativeRoot).filter(isMediaFile).length + if (remoteMode) check(packagedMediaFiles === 0, 'Unexpected native packaged media in COS mode') + for (const page of manifest.allPages) { + check(allRoutes.filter(route => route === page).length === 1, `Page registration missing or duplicated: ${page}`) + for (const extension of ['.js', '.json', '.wxml', '.wxss']) check(fs.existsSync(path.join(output, `${page}${extension}`)), `Missing page file: ${page}${extension}`) + const config = JSON.parse(fs.readFileSync(path.join(output, `${page}.json`), 'utf8')) + check(config.navigationStyle === 'custom' && config.pageOrientation === 'landscape', `Native display config missing: ${page}`) + const script = fs.readFileSync(path.join(output, `${page}.js`), 'utf8') + check(!/^Page\(\{/m.test(script) && /require\(["']\.\.\/(?:\.\.\/)*utils\/tangPage\.js["']\)\(\{/.test(script), `Page lifecycle wrapper missing: ${page}`) + const style = fs.readFileSync(path.join(output, `${page}.wxss`), 'utf8') + check(/@import\s+["']\/tang-detective\/shared\.wxss["'];/.test(style), `Native style import missing: ${page}`) + } + let dependencies = 0 + for (const relative of listFiles(nativeRoot).filter(file => file.endsWith('.js'))) { + const filename = path.join(nativeRoot, relative) + const script = fs.readFileSync(filename, 'utf8') + try { new vm.Script(script, { filename: relative }) } catch (error) { errors.push(error.message) } + for (const [, specifier] of script.matchAll(/require\(['"]([^'"]+)['"]\)/g)) { + dependencies += 1 + if (!specifier.startsWith('.')) { errors.push(`Non-relative native dependency: ${relative} -> ${specifier}`); continue } + try { + const resolved = localRequire.resolve(path.resolve(path.dirname(filename), specifier)) + const dependency = path.relative(output, resolved).split(path.sep).join('/') + check(dependency.startsWith('tang-detective/'), `Dependency escaped namespace: ${relative} -> ${specifier}`) + check(packageOwner(dependency) === 'main' || packageOwner(dependency) === packageOwner(`tang-detective/${relative}`), `Illegal sibling package import: ${relative} -> ${specifier}`) + } catch (error) { errors.push(`Unresolved require: ${relative} -> ${specifier}: ${error.message}`) } + } + } + const outputSizes = { totalFileBytes: 0, mainPackageFileBytes: 0, subPackages: {} } + const outputPackages = hostApp.subPackages || hostApp.subpackages || [] + for (const relative of listFiles(output)) { + const bytes = fs.statSync(path.join(output, relative)).size + outputSizes.totalFileBytes += bytes + const subpackage = outputPackages.find(item => relative.startsWith(`${item.root}/`)) + if (subpackage) outputSizes.subPackages[subpackage.root] = (outputSizes.subPackages[subpackage.root] || 0) + bytes + else outputSizes.mainPackageFileBytes += bytes + } + return { + passed: errors.length === 0, + nativePages: manifest.allPages.length, + mediaFiles, + packagedMediaFiles, + mediaMode: remoteMode ? 'cos' : 'local', + relativeDependencies: dependencies, + nativeSizes: imported.sizes, + outputSizes, + errors, + boundary: 'Static source/asset/dependency/manifest verification only. Raw file sizes are not WeChat upload package sizes; no simulator, device, audio listening, upload, or release acceptance was performed.', + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const report = validateNativeOutput(path.resolve(projectRoot, process.argv[2] || 'dist/build/mp-weixin')) + if (process.argv[3]) fs.writeFileSync(path.resolve(projectRoot, process.argv[3]), `${JSON.stringify(report, null, 2)}\n`) + console.log(JSON.stringify(report, null, 2)) + if (!report.passed) process.exitCode = 1 +} diff --git a/TongjiUniApp/build/validate-tang-tongji-host.mjs b/TongjiUniApp/build/validate-tang-tongji-host.mjs new file mode 100644 index 0000000..2e2d9ed --- /dev/null +++ b/TongjiUniApp/build/validate-tang-tongji-host.mjs @@ -0,0 +1,37 @@ +import fs from 'node:fs' +import path from 'node:path' +import vm from 'node:vm' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const output = path.resolve(projectRoot, process.argv[2] || 'dist/build/mp-weixin') +const config = JSON.parse(fs.readFileSync(path.join(output, 'app.json'), 'utf8')) +const sourcePages = JSON.parse(fs.readFileSync(path.join(projectRoot, 'pages.json'), 'utf8')) +const allPages = new Set([...config.pages, ...(config.subPackages || config.subpackages || []).flatMap(pkg => pkg.pages.map(page => `${pkg.root}/${page}`))]) +assert.equal(config.pages[0], 'tongji/pages/weekly', 'The standalone Tongji home must remain the startup page') +for (const page of sourcePages.pages) assert(allPages.has(page.path), `Missing existing host route: ${page.path}`) +for (const pkg of sourcePages.subPackages || []) { + for (const page of pkg.pages) assert(allPages.has(`${pkg.root}/${page.path}`), `Missing host subpackage route: ${pkg.root}/${page.path}`) +} +assert(allPages.has('tongji/tang-detective/index')) +assert(allPages.has('tang-detective/pages/home/home')) +assert(allPages.has('tongji/endless-game/index')) +const apiText = fs.readFileSync(path.join(projectRoot, 'config/api.js'), 'utf8') +const sourceApi = apiText.match(/export\s+const\s+API_BASE_URL\s*=\s*['"]([^'"]+)['"]/) +assert(sourceApi, 'Public API config must export API_BASE_URL') +const platform = { module: { exports: {} } } +vm.runInNewContext(fs.readFileSync(path.join(output, 'tang-detective/utils/platformConfig.js'), 'utf8'), platform, { timeout: 1000 }) +assert.deepEqual(Object.keys(platform.module.exports), ['apiBaseUrl'], 'Only the public API base belongs in native platformConfig') +assert.equal(platform.module.exports.apiBaseUrl, sourceApi[1], 'Native progress calls must use this host API, not the previous project API') +const weekly = fs.readFileSync(path.join(output, 'tongji/pages/weekly.wxml'), 'utf8') +assert(weekly.includes('唐侦探'), 'The standalone weekly page must contain the game entry') +assert(weekly.includes('识糖小课堂'), 'Existing match-three entry must remain visible') +assert(!fs.existsSync(path.join(output, 'TUICallKit')), 'Do not import the unrelated call SDK into TongjiUniApp') +const home = fs.readFileSync(path.join(output, 'tang-detective/pages/home/home.js'), 'utf8') +assert(home.includes("wx.reLaunch({ url: '/tongji/pages/weekly' })"), 'Return must target the standalone host home') +console.log(JSON.stringify({ passed: true, project: 'TongjiUniApp', startupPage: config.pages[0], + sourceHostRoutes: sourcePages.pages.length + (sourcePages.subPackages || []).reduce((sum, pkg) => sum + pkg.pages.length, 0), + nativePages: [...allPages].filter(page => page.startsWith('tang-detective/')).length, + apiBaseUrl: sourceApi[1], unrelatedCallSdkImported: false, + boundary: 'Built output routing/configuration checks only; not a live backend or device test.' }, null, 2)) diff --git a/TongjiUniApp/config/api.js b/TongjiUniApp/config/api.js new file mode 100644 index 0000000..c186e06 --- /dev/null +++ b/TongjiUniApp/config/api.js @@ -0,0 +1,3 @@ +// Shared by the host API client and the native Tang Detective build adapter. +// This is a public endpoint, never a place for tokens or API secrets. +export const API_BASE_URL = 'https://xt.zhenyangtang.com.cn/' diff --git a/TongjiUniApp/docs/TANG-COS-MIGRATION-20260908.md b/TongjiUniApp/docs/TANG-COS-MIGRATION-20260908.md new file mode 100644 index 0000000..6c0979a --- /dev/null +++ b/TongjiUniApp/docs/TANG-COS-MIGRATION-20260908.md @@ -0,0 +1,62 @@ +# 唐侦探真实素材上云与清理 + +正确项目:`xuetang/TongjiUniApp`。上传批次:`b9ab703a-49a6-4b6b-ac34-979eca1d4828`。 + +## 已完成上传与核验 + +用户自行登录 `https://xt.zhenyangtang.com.cn/admin/` 后,通过已有图片上传和语音直传界面上传;没有提取登录令牌或长期密钥,没有点击“确定发布”,没有改变桶权限。后台显示腾讯云 COS(对象存储)开启。原始文件仅复制为带序号的上传副本,没有生成、重编码、裁剪或压缩内容。 + +- 204 条唐侦探媒体路径:160 JPG、44 MP3、0 视频,总原始字节 24,052,082。 +- 去重后 180 个实际对象:136 图片、44 音频。所有对象已完成不带凭据的 HTTPS(加密传输)完整下载,长度、类型和 SHA-256(摘要)均与原件一致。 +- 全部 44 音频另通过 Range(分段下载)206 状态、范围头及首段原始字节一致性检查。 +- 实际使用现有域名 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com`。图片位于 `uploads/images/20260908/`,语音位于 `uploads/voice/20260908/`;完整 URL(地址)逐条保存在真实清单。 + +证据文件: + +- `build/tang-detective-source-manifest.json`:原始 473 项快照,保持原字节与固定摘要。 +- `build/tang-detective-admin-upload-plan.json`:去重与上传副本对应关系;它本身不是成功证明。 +- `build/tang-detective-admin-upload-observations.json`:管理界面实际返回地址,数组与上传副本序号对应。 +- `build/tang-detective-admin-upload-receipt.json`:180 个对象的真实完整回读结果,`complete=true`。 +- `build/tang-detective-cos-manifest.json`:完整 204 条路径的激活清单;仅全部验证通过后生成。 +- `build/tang-detective-admin-audit-receipt.json`:清理后再次真实回读全部 180 对象,`complete=true`;不改写原激活清单。 +- `build/tang-detective-cleanup-verification.json`:实际清理后源码、恢复副本、测试、构建与本机微信编译器结果和命令输出,`passed=true`。 + +## 清理状态 + +独立代码审查通过后,已将 204 个被真实远端地址替代的媒体,以及 10 个没有运行时代码引用的旧图片移出项目,共 214 文件、24,145,441 字节。凭证为 `build/tang-detective-media-prune-receipt.json`,绑定源快照、真实远端清单、上传批次、备份摘要与每条移出记录。没有删除共享后端、此前误迁的 `TUICallKit-Vue3`、原唐侦探项目、图标字体或运行库。 + +保留 2 张个人中心导航图标和 5 张仍有业务数据引用的订单图片。10 个无引用旧图片的精确清单在 `scripts/tang-cos/prune-verified-media.mjs`,其范围不能扩大为按扩展名批量删除。 + +项目外备份:`/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/original-media.tar.gz`。归档含 221 个原媒体及源快照清单,222 项曾全部逐字节回读;清理预检查又独立回读了全部 214 个待移出条目。 + +归档 SHA-256:`656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61`。归档没有密钥或登录配置。移出的原字节文件还保存在同目录 `pruned-originals/`,项目本身不需要访问此路径即可构建。 + +## 怎样运行和复核 + +在 `TongjiUniApp` 中执行: + +```sh +npm run test:tang +npm run build:mp-weixin +npm run check:tang-output +node scripts/check-tang-native-compiler.cjs +node scripts/tang-cos/verify-admin-upload.mjs audit +``` + +最后一条只读取公开 COS 文件并写本地审计回执,不会再上传素材或改写激活清单。正常构建自动读取项目内清单;缺少媒体时必须同时提供完整、匹配的清理凭证,269 个非媒体源文件始终逐项校验。不应重新生成源快照、删除清单或用测试替身覆盖真实清单。 + +需要恢复本地原件时,应从上述项目外原件目录按精确路径恢复,并先核对备份及目标是否已有改动;不要覆盖现有代码。旧直连上传命令要求完整原件与可信数据库证书,此次没有绕过数据库证书错误,而是使用正常已登录上传通道。 + +## 已实测与限制 + +清理后的实际项目再次验证:81/81 自动测试通过;微信构建退出 0;24 个原生页、525 个相对依赖、原宿主路由与 API(接口)地址校验通过;唐侦探源目录和产物目录媒体数均为 0;本机微信 `wcc` / `wcsc` 对 24 页均编译通过。269 个非媒体源码及 214 个移出的恢复副本全部逐字节通过检查。另对原唐侦探项目的全部 473 个文件再次比对原快照,摘要与长度均一致。 + +实际产物已更新至 `TongjiUniApp/dist/build/mp-weixin`:原始构建文件总大小 7,438,230 字节,主包原始文件大小 1,412,653 字节。这些是磁盘文件字节统计,不是微信上传压缩包测量或发布通过证明。项目仅余 7 张仍有业务引用的宿主图片,无唐侦探本地图片、音频或视频。 + +老年友好小游戏验证规范用于保留原 120 页选图及 112 正式/8 临时状态,不提升音频试听、内容审核、可玩性或医疗审批状态。网络回读和编译不等于微信真机播放、账号云存档、老人体验、医生审签或公开发布。本次没有部署后端、提交数据库迁移、推送远端版本库、提交审核或发布小程序。 + +## 专业审查与资源收尾 + +微信小程序专业智能体完成严格删源后构建与清单校验,29/29 专项测试通过;代码审查专业智能体完成独立安全检查,44/44 隔离测试通过,未发现阻止本次清理的问题。主智能体负责真实上传、公开回读、备份核验、可恢复移出和实际项目删后复验。 + +上传/选择对话框已关闭,页面无音视频播放器;测试、编译、公开下载进程均已结束,没有新增监听服务。为保护用户工作,保留已登录后台、原有微信开发者工具及其他既有窗口;项目外归档和原字节恢复目录保留。无关闭失败资源。 diff --git a/TongjiUniApp/docs/TANG-COS-UPLOAD-BLOCKER-20260908.md b/TongjiUniApp/docs/TANG-COS-UPLOAD-BLOCKER-20260908.md new file mode 100644 index 0000000..9672f0d --- /dev/null +++ b/TongjiUniApp/docs/TANG-COS-UPLOAD-BLOCKER-20260908.md @@ -0,0 +1,50 @@ +# 素材上云与清理:历史阻塞和恢复点 + +本页记录用户登录后台之前的失败尝试,已被后续正常管理界面上传解除。当前真实结果请看 [2026-09-08 素材迁移记录](TANG-COS-MIGRATION-20260908.md) 与 `build/tang-detective-admin-upload-receipt.json`,不要把下面历史“上传 0”当作当前状态。数据库证书信任问题本身未被绕过或修复。 + +2026-09-08 用户再次明确:把图片、音频上传到 `xuetang/server` 配置的 COS(对象存储),随后删除 `TongjiUniApp` 中不必要的本地图音文件。上传、替换真实引用、校验和可恢复清理均已获授权;未授权绕过证书校验、改变桶权限、删除云端对象或发布小程序。 + +## 本次实际结果 + +- 安装了 `scripts/tang-cos` 锁定的 77 个工具依赖,未升级依赖、未改变小程序运行依赖。 +- `node scripts/tang-cos/upload.mjs inspect` 退出码 1,脱敏错误码 `HANDSHAKE_SSL_ERROR`;仅进一步确认其属于数据库自签名证书信任失败,没有输出原始连接信息。 +- 未取得实际 COS 配置或密钥;未发出 COS 写入请求;上传数为 0,删除数为 0,没有伪造真实激活清单。 +- 后端专业智能体确认存储配置来自配置表中的 `storage/default` 和 `storage/qcloud`;现有仓库没有数据库专用可信 CA(证书颁发机构)文件。本机 SSH(加密远程连接)配置中没有匹配该数据库主机的入口。 +- 已检查服务器现有管理入口 `https://xt.zhenyangtang.com.cn/admin/`,实际进入后台登录页,未登录。该标签页作为用户登录接续入口保留;未尝试账号或密码,也未读取浏览器凭据。 + +接续方式:请用户自行登录已打开的管理后台。登录后可检查现有图片/文件上传界面与配置驱动,通过正常已授权上传流程处理素材。不要从后台提取或输出长期密钥。若后台通道不可用,另一选项是管理员通过可信渠道提供数据库 CA 文件,再使用现有 `TANG_DB_CA_FILE` 配置;不能抓取对端自签名证书后自行建立信任。 + +## 已完成项目外备份 + +备份位置:`/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/original-media.tar.gz`。 + +- 221 个图音素材,合计 24,179,649 字节,另含原唐侦探源文件摘要清单;排除了依赖、构建产物和运行库。 +- 全部 222 个压缩包条目已逐条回读,与当前原件逐字节相等。 +- 压缩包 SHA-256:`656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61`。 +- 其中唐侦探为 204 条媒体路径:160 JPG、44 MP3,合计 24,052,082 字节;去重后 180 个对象。 +- 未删除或移动任何原件。备份不包含 `.env`、账号、登录令牌或 API 密钥文件。 + +## 清理前还要补齐 + +当前构建的远程模式能移除产物媒体,但校验器仍要求项目源目录中的全部媒体存在,不能直接删除源图片/音频后宣布完成。 + +微信开发专业智能体已确认最小变更范围:共享严格源清单校验;完整且真实的远程登记表可以承接缺失媒体,但必须保留全部非媒体源文件,拒绝额外文件、原件漂移、符号链接和不完整清单。本地模式缺原件必须失败。上传工具继续要求完整原件,清理回执绑定上传、远程登记表和外部备份摘要。 + +10 个无运行时引用的图音候选共 93,359 字节,尚未删除: + +- `static/background.svg` +- `static/calling-logo.png` +- `static/check.png` +- `static/user/home.png` +- `static/user/home_no.png` +- `static/wjw.png` +- `static/ys.png` +- `static/yy.png` +- `static/zs.jpg` +- `training/static/footprint.svg`(仅说明文件提及的备用素材) + +保留仍被引用的个人中心图标、订单图、图标字体和不属于此次图音范围的运行库。实际删除前须重新核对引用、真实上传/公开回读与备份,不能把本清单当作已执行结果。 + +## 资源收尾 + +本次依赖安装、检查和归档命令均已结束,无临时监听服务或音视频播放。保留后台登录接续页和项目外备份;未操作或关闭已有小程序窗口、旧项目窗口及用户其他网页。无关闭失败。代码审查专业智能体未调用,因为尚未实施远程替换或删除。 diff --git a/TongjiUniApp/docs/TANG-DETECTIVE-INTEGRATION.md b/TongjiUniApp/docs/TANG-DETECTIVE-INTEGRATION.md new file mode 100644 index 0000000..e5f3b44 --- /dev/null +++ b/TongjiUniApp/docs/TANG-DETECTIVE-INTEGRATION.md @@ -0,0 +1,95 @@ +# 唐侦探迁入 TongjiUniApp + +核对日期:2026-09-08。本说明针对目录纠正后的 `xuetang/TongjiUniApp`,不是此前的 `xuetang/TUICallKit-Vue3`。用户登录后台后的素材上云结果见 [素材实测与清理记录](TANG-COS-MIGRATION-20260908.md);下方“迁移初始实测记录”保留历史阶段证据。 + +## 当前结果与边界 + +- 入口位于原首页 `tongji/pages/weekly`,作为与「识糖小课堂」并列的图标卡片。 +- 微信端经 `tongji/tang-detective/index` 打开唐侦探原生首页;原 9 个主页面、5 个训练页面和登录/健康记录流程的源码保留。 +- 迁入 24 个原生页面、15 回故事;保留原始 473 项源快照与摘要,其中 269 个非媒体文件必须完整。媒体上云不改变原始摘要,没有重新生成图片、音频或视频。 +- `native-adapter/tang-detective` 隔离宿主与原生游戏的路径、页面生命周期、账号存档和媒体适配;不替换宿主 `App.vue`,不引入 TUICallKit 通话 SDK(软件开发工具包)。 +- H5(网页端)可构建;唐侦探入口显示需从微信打开的提示,不宣称网页端已可游玩。 +- 已通过正常登录后的管理界面上传素材,未提取长期密钥、改变桶权限、发布分发资源、部署后端、执行数据库迁移或推送 Git(版本库)。真机与发布验收仍未完成。 + +## 如何运行 + +在本项目目录执行: + +```sh +cd /Users/dagedagededagege/Pictures/xuetang/TongjiUniApp +npm ci --ignore-scripts --no-audit --no-fund +npm run test:tang +npm run build:mp-weixin +npm run check:tang-output +node scripts/check-tang-native-compiler.cjs +``` + +微信开发者工具应导入本项目的 `dist/build/mp-weixin`,不要导入之前的 TUICallKit-Vue3 产物。首页进入「唐侦探」。命令行原生编译检查使用本机已安装的微信编译器;其他机器可用 `TANG_WECHAT_COMPILER_DIR` 指定编译器所在目录。该检查仅验证模板/样式语法,不会打开模拟器、登录、播放或上传。 + +网页端构建命令: + +```sh +npm run build:h5 +``` + +不升级原依赖版本。开发依赖沿用项目 `package-lock.json`,COS 工具可选依赖单独锁定在 `scripts/tang-cos/package-lock.json`,正常构建与本地测试不需要安装该可选依赖。 + +## 接口对接 + +`config/api.js` 是公开接口地址的单一来源,保留此宿主原地址 `https://xt.zhenyangtang.com.cn/`。`main.js` 与原生构建插件共用它;生成的 `tang-detective/utils/platformConfig.js` 只包含公开地址,不包含密钥或登录令牌。 + +游戏复用宿主登录后的 `token` 请求头: + +- `GET /api/tang/catalog` +- `GET /api/tang/progress` +- `POST /api/tang/saveProgress` + +只同步受白名单约束的章节、事件、卡片 ID(标识)和阅读位置;不上传健康回答、诊疗数据、剧情文字快照或音频。访客存档本地保留,不在登录后自动合并上传。账号切换、断网、登录过期及冲突场景保留独立状态,不能把请求已发送当作云端确认。 + +接口实现位于同级 `../server`,沿用先前实现,目录纠正未再次改动后端。契约及部署前置条件见 [后端说明](../../server/docs/tang-detective-progress.md) 和 [接口契约](../../server/docs/tang-detective.openapi.yaml)。本轮未对 `xt.zhenyangtang.com.cn` 执行真实登录与存档联调;其他项目域名的历史结果不能用于判断此域名已成功或失败。 + +## 素材与 COS(对象存储) + +源素材为 160 张图片、44 个音频、0 个视频,共 204 条路径,去重后 180 个对象,合计 24,052,082 字节。原始摘要清单不改写;清理后由真实远端清单和项目内清理凭证共同承接缺失媒体,非媒体源文件仍需逐项通过校验。 + +不需要任何密钥的远端回读复核命令(只发公开读取请求,生成本地审计回执): + +```sh +node scripts/tang-cos/verify-admin-upload.mjs audit +``` + +本次使用 `xuetang/server` 已配置并开启的 COS,通过已有管理界面上传。实际地址位于 `gz-1349751149.cos.ap-guangzhou.myqcloud.com` 下的 `uploads/images/20260908/` 和 `uploads/voice/20260908/`;地址来自上传结果,不由客户端猜测。清单第二版严格约束已实测的域名与对象命名。 + +`build/tang-detective-cos-manifest.json` 已由完整公开回读验证后激活。180 个对象的长度、SHA-256(摘要)、媒体类型全部一致;44 个音频均通过 Range(分段下载)206 与片段字节校验。实际回执为 `build/tang-detective-admin-upload-receipt.json`;旧直连配置失败的历史回执不代表当前上传状态。 + +原直连上传工具仍保留,要求完整原件与可信数据库证书;清理后直接运行其 `inventory` 或 `upload` 会明确拒绝缺失原件。不要关闭证书校验、重新生成源摘要或用测试清单激活项目。正常构建不读取数据库、密钥或项目外备份路径。 + +## 迁移初始实测记录(素材上云前的历史阶段) + +| 检查 | 本轮结果 | 不能据此证明 | +| --- | --- | --- | +| `npm run test:tang` | 72/72 通过 | 真实服务器与存储可用;COS 相关网络用例使用替身 | +| 微信构建 | 退出码 0 | 模拟器可操作、真机可用、上传通过 | +| `npm run check:tang-output` | 24 个原生页面、204 个媒体、328 个相对依赖通过;启动页与原宿主路由保留;原生接口地址与本宿主一致 | 云端存档成功 | +| 微信 `wcc` / `wcsc` 编译器 | 24 页均通过,退出码均为 0 | 界面、音质与播放体验验收 | +| `npm run build:h5` | 退出码 0 | 唐侦探支持网页游玩 | +| 原件与迁入快照 | 473/473 逐字节相同 | 内容、声音授权或医疗审签通过 | +| 原误迁目录及共享后端保护 | 515 个选定文件组合摘要与本轮开始时一致 | 整个仓库未被其他任务修改 | + +仍待验证:目标部署接口与数据库、微信合法域名的当前配置、开发者工具完整交互与真机播放/存档、老年用户体验、医疗内容复核、发布验收。真实 COS 地址的公开字节回读已在后续阶段完成,但不等于微信真机验收。老年友好小游戏验证规范用于保持原玩法、素材与审核状态,不因迁移提升任何内容的验收等级。 + +## 文件保护与收尾 + +本轮没有删除此前误迁到 `TUICallKit-Vue3` 的内容;原唐侦探和共享后端均保留。新依赖、源码、构建产物保留在正确项目中供复核。测试与构建进程已结束,没有打开新窗口、网页、播放器或常驻服务;既有微信开发者工具窗口未操作、未关闭。 + +## 后续小程序同步复核(2026-09-08 16:26) + +用户继续要求「小程序也同步一下」后,重新执行微信构建和产物检查,均以退出码 0 结束。 + +开发者工具起初仍打开旧 TUICallKit-Vue3 产物;已通过导入选择框定位正确目录。后续复核时,工具已打开 `TongjiUniApp/dist/build/mp-weixin`,窗口名称为 `tongji-uniapp`,模拟器实际显示默认首页 `tongji/pages/weekly`,可见「唐侦探」和「识糖小课堂」两个入口。没有修改调试启动配置,没有清除存档、点击业务提交、播放音频或上传发布;没有执行完整唐侦探游玩与云存档验证。 + +微信开发专业智能体只读核对了配置、路由、接口及启动行为。需注意:宿主启动会自动登录并读取业务信息;默认首页在特定账号状态下可能自动创建或关联健康记录上下文,进入唐侦探也可能提交已有待同步进度。因此本次界面运行不能称为纯离线检查,也不能仅根据首页可见断言后台没有自动写入。 + +当时开发者工具代码质量面板显示主包尺寸、未使用脚本及本地图片/音频资源项目未通过。此处为上云前历史提示,不能作为后续远端产物的当前结论;后续体积变化见素材清理记录。线上接口联调、真机与发布验收边界不变。 + +收尾:构建和检查进程已结束,导入选择框已关闭。当前正确项目窗口保留供用户继续使用;原有窗口和应用未主动关闭。没有新增临时服务或本任务启动的音视频播放,也无关闭失败项。 diff --git a/TongjiUniApp/native-adapter/tang-detective/pages/catalog/catalog.js b/TongjiUniApp/native-adapter/tang-detective/pages/catalog/catalog.js new file mode 100644 index 0000000..d6d1ffa --- /dev/null +++ b/TongjiUniApp/native-adapter/tang-detective/pages/catalog/catalog.js @@ -0,0 +1,99 @@ +const chapters = require('../../data/chapters') +const { + getProgress, + saveProgress, + resetStoryProgress, + getSettings, +} = require('../../utils/storage') +const { chapterRoute } = require('../../utils/chapterRoute') +const bridge = require('../../utils/platformBridge') + +Page({ + data: { + chapters: [], + fontScale: 'large', + catalogListEnded: false, + }, + + onShow() { + const progress = getProgress() + const settings = getSettings() + const completed = new Set(progress.completedChapters) + const lastChapter = Number(progress.lastChapter) || 1 + const completedHotspots = progress.completedHotspots || {} + this.setData({ + chapters: chapters.map((chapter) => ({ + ...chapter, + completed: completed.has(chapter.chapterId), + current: chapter.number === lastChapter, + readingLabel: completed.has(chapter.chapterId) + ? '重新翻看这一回' + : chapter.number === lastChapter + ? '上次读到这里' + : '翻开这一回', + eventCount: Array.isArray(completedHotspots[chapter.chapterId]) + ? completedHotspots[chapter.chapterId].length + : 0, + })), + lastChapter, + fontScale: settings.fontScale === 'xlarge' ? 'xlarge' : 'large', + catalogListEnded: false, + }) + }, + + markListEnd() { + if (!this.data.catalogListEnded) { + this.setData({ catalogListEnded: true }) + } + }, + + openChapter(event) { + const chapter = Number(event.currentTarget.dataset.chapter) + const progress = getProgress() + const chapterMeta = chapters.find((item) => item.number === chapter) + const replay = Boolean( + chapterMeta + && progress.completedChapters.includes(chapterMeta.chapterId), + ) + progress.lastChapter = chapter + saveProgress(progress) + wx.navigateTo({ url: chapterRoute(chapter, { replay }) }) + }, + + restartStory() { + const expectedScope = bridge.getScope() + const visibleGeneration = this.__tangShowGeneration + wx.showModal({ + title: '从第一回重新开始?', + content: '关卡进度会清空;已经收藏的“我的桂香岁月”和大字设置会保留。', + confirmText: '重新开始', + cancelText: '先不清空', + confirmColor: '#9f3028', + success: (result) => { + if (!result || !result.confirm || this.__tangDead || !this.__tangVisible + || visibleGeneration !== this.__tangShowGeneration + || expectedScope !== bridge.getScope()) return + if (!resetStoryProgress(expectedScope)) { + wx.showToast({ title: '进度暂时没有清空,请稍后再试', icon: 'none' }) + return + } + wx.redirectTo({ url: chapterRoute(1) }) + }, + }) + }, + + goBack() { + wx.reLaunch({ url: '/tang-detective/pages/home/home' }) + }, + + onShareAppMessage() { + return { + title: '唐侦探:翻开十五回桂香故事', + path: '/tang-detective/pages/catalog/catalog', + } + }, + + onShareTimeline() { + return { title: '唐侦探:翻开十五回桂香故事', query: '' } + }, +}) diff --git a/TongjiUniApp/native-adapter/tang-detective/pages/home/home.js b/TongjiUniApp/native-adapter/tang-detective/pages/home/home.js new file mode 100644 index 0000000..25d5da6 --- /dev/null +++ b/TongjiUniApp/native-adapter/tang-detective/pages/home/home.js @@ -0,0 +1,184 @@ +const memoryCards = require('../../data/memoryCards') +const { getProgress } = require('../../utils/storage') +const { getCollectedMemories } = require('../../utils/memoryCollection') +const { chapterRoute } = require('../../utils/chapterRoute') +const bridge = require('../../utils/platformBridge') +const STATUS_TEXT = { + guest: '游客阅读 · 仅存本机', offline: '离线存档 · 点此重试', pending: '本机已保存 · 等待同步', + connecting: '正在连接存档…', syncing: '本机已保存 · 正在同步', synced: '阅读存档已同步', + conflict: '存档有冲突 · 点此选择', 'auth-expired': '登录已失效 · 本机记录保留', + 'storage-error': '本机未存成功 · 请检查空间', 'version-error': '存档版本不一致 · 仅本机阅读', + 'sync-error': '存档未同步 · 请更新后重试', +} +const { + getHomeLayoutMetrics, + getHomeLayoutStyle, + hasReadingProgress, +} = require('./homeLayout') + +Page({ + data: { + tangStatusText: '正在读取存档…', + coverOpened: false, + completedCount: 0, + lastChapter: 1, + hasReadingProgress: false, + memoryCount: 0, + homeLayoutStyle: [ + 'height:390px', + '--home-top-inset:8px', + '--home-right-inset:10px', + '--home-bottom-inset:7px', + '--home-left-inset:10px', + ].join(';'), + }, + + onLoad() { + this.unsubscribeTang = bridge.subscribe(() => this.refreshTangStatus()) + this.refreshTangStatus() + this.applyHomeLayout() + }, + + onShow() { + this.refreshHomeProgress() + this.refreshTangStatus() + }, + + onUnload() { + if (this.unsubscribeTang) this.unsubscribeTang() + }, + + refreshHomeProgress() { + const progress = getProgress() + this.setData({ + completedCount: progress.completedChapters.length, + lastChapter: progress.lastChapter || 1, + hasReadingProgress: hasReadingProgress(progress), + memoryCount: getCollectedMemories(progress, memoryCards).length, + }) + this.applyHomeLayout() + }, + + refreshTangStatus() { + this.setData({ tangStatusText: STATUS_TEXT[bridge.getStatus()] || '本机阅读存档' }) + }, + + async syncTang() { + const scope = bridge.getScope() + const visibleGeneration = this.__tangShowGeneration + const stillActive = () => !this.__tangDead && this.__tangVisible + && this.__tangShowGeneration === visibleGeneration && bridge.getScope() === scope + if (!wx.getStorageSync('token')) { + wx.showToast({ title: '请先在学堂登录,游客记录不会自动上传', icon: 'none' }) + return + } + await bridge.open(true) + if (!stillActive()) return + if (bridge.getStatus() !== 'conflict') { this.refreshHomeProgress(); return } + const conflictContext = bridge.getConflictContext() + wx.showActionSheet({ + itemList: ['采用云端存档', '用本机存档覆盖云端'], + success: result => { + if (!stillActive() || bridge.getConflictContext() !== conflictContext) return + const choice = result.tapIndex === 0 ? 'cloud' : 'local' + wx.showModal({ + title: '确认阅读存档', + content: choice === 'cloud' ? '将用云端进度替换当前本机进度。原记录会保留一份本机备份。' + : '将用本机进度覆盖云端,其他设备的未同步阅读可能不在其中。原记录会保留一份本机备份。', + confirmText: '确认使用', + success: async answer => { + if (answer.confirm && stillActive()) { + await bridge.resolveConflict(choice, conflictContext) + if (stillActive()) this.refreshHomeProgress() + } + }, + }) + }, + }) + }, + + returnToXuetang() { + bridge.flush() + wx.reLaunch({ url: '/tongji/pages/weekly' }) + }, + + onResize(resizeInfo) { + this.applyHomeLayout(resizeInfo) + }, + + applyHomeLayout(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + + const resizeSize = resizeInfo && resizeInfo.size + ? resizeInfo.size + : resizeInfo + if (resizeSize) { + windowInfo = { + ...windowInfo, + windowWidth: resizeSize.windowWidth || windowInfo.windowWidth, + windowHeight: resizeSize.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getHomeLayoutMetrics(windowInfo, menuRect) + this.setData({ + homeLayoutStyle: getHomeLayoutStyle(metrics), + }) + }, + + startGame() { + const chapter = this.data.lastChapter || 1 + wx.navigateTo({ + url: chapterRoute(chapter), + }) + }, + + revealCover() { + if (this.data.coverOpened) return + this.setData({ coverOpened: true }) + }, + + keepCoverOpen() {}, + + openCatalog() { + wx.navigateTo({ url: '/tang-detective/pages/catalog/catalog' }) + }, + + openCast() { + wx.navigateTo({ url: '/tang-detective/pages/cast/cast' }) + }, + + openMemories() { + wx.navigateTo({ url: '/tang-detective/pages/memories/memories' }) + }, + + onShareAppMessage() { + return { + title: '唐侦探:在一桌饭里,看见被忽略的人', + path: '/tang-detective/pages/home/home', + } + }, + + onShareTimeline() { + return { + title: '唐侦探:在一桌饭里,看见被忽略的人', + query: '', + } + }, +}) diff --git a/TongjiUniApp/native-adapter/tang-detective/pages/home/home.wxml b/TongjiUniApp/native-adapter/tang-detective/pages/home/home.wxml new file mode 100644 index 0000000..5c5135a --- /dev/null +++ b/TongjiUniApp/native-adapter/tang-detective/pages/home/home.wxml @@ -0,0 +1,69 @@ + + + + + + + + + + 甄养堂 · 中国健康连环画 + + 第一季 + 唐侦探 + 桂香里的第十五桌 + + + + 翻开瞧瞧 + + + + + + + + 甄养堂 + 一本能看、能点、能带回家聊的中国健康连环画 + + + + 桂香里的第十五桌 + 第一季 · 一桌饭里的三代人 + “一桌饭,不应该只有坐下的人,还应该有被看见的人。” + + + + + + + + + + 先看画、听故事;翻到背面,再聊聊这回事。 + + + + 这里聊的是日常生活,不替代诊断、处方或个体化治疗建议。 + diff --git a/TongjiUniApp/native-adapter/tang-detective/pages/home/home.wxss b/TongjiUniApp/native-adapter/tang-detective/pages/home/home.wxss new file mode 100644 index 0000000..e27128f --- /dev/null +++ b/TongjiUniApp/native-adapter/tang-detective/pages/home/home.wxss @@ -0,0 +1,683 @@ +@import "/tang-detective/shared.wxss"; + +.tang-host-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex: 0 0 44px; padding-right: 92px; } +.tang-host-button, .tang-sync-button { margin: 0; min-height: 44px; padding: 0 12px; line-height: 44px; font-size: 16px; border-radius: 8px; color: #f3e5bd; background: #30251c; } +.tang-sync-button { font-size: 14px; } +.home-shell { + --home-top-inset: calc(8px + constant(safe-area-inset-top)); + --home-right-inset: calc(10px + constant(safe-area-inset-right)); + --home-bottom-inset: calc(7px + constant(safe-area-inset-bottom)); + --home-left-inset: calc(10px + constant(safe-area-inset-left)); + --home-top-inset: calc(8px + env(safe-area-inset-top)); + --home-right-inset: calc(10px + env(safe-area-inset-right)); + --home-bottom-inset: calc(7px + env(safe-area-inset-bottom)); + --home-left-inset: calc(10px + env(safe-area-inset-left)); + display: flex; + height: 100vh; + min-height: 0; + padding: var(--home-top-inset) var(--home-right-inset) + var(--home-bottom-inset) var(--home-left-inset); + gap: 6px; + overflow: hidden; + flex-direction: column; +} + +.home-comic-book { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #33251b; + background: #1d130f; + border: 3px solid #9e845e; + border-radius: 10px; + box-shadow: 0 15px 34px rgba(0, 0, 0, 0.36); + grid-template-columns: minmax(0, 1fr); +} + +.home-comic-book.is-open { + grid-template-columns: minmax(0, 1.45fr) minmax(280px, 1fr); +} + +.home-cover-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #241811; +} + +.home-comic-book.is-open .home-cover-art { + border-right: 7px solid #6f2b23; + box-shadow: 10px 0 24px rgba(32, 19, 12, 0.34); +} + +.home-cover-image, +.home-cover-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.home-cover-shade { + pointer-events: none; + background: + linear-gradient(90deg, rgba(29, 18, 12, 0.68), transparent 24%, transparent 72%, rgba(29, 18, 12, 0.2)), + linear-gradient(0deg, rgba(31, 18, 12, 0.82), transparent 53%); + box-shadow: inset 0 0 70px rgba(30, 16, 9, 0.3); +} + +.home-cover-spine { + position: absolute; + z-index: 3; + top: 0; + bottom: 0; + left: 0; + display: flex; + width: 46px; + align-items: center; + justify-content: center; + color: #f0d8a7; + background: rgba(74, 35, 26, 0.94); + border-right: 2px solid #c59a58; + font-family: "Songti SC", "STSong", serif; + font-size: 15px; + font-weight: 850; + letter-spacing: 3px; + writing-mode: vertical-rl; +} + +.home-cover-title-block { + position: absolute; + z-index: 3; + left: 72px; + bottom: 54px; + display: flex; + max-width: 64%; + padding: 13px 19px 15px; + color: #f7e7c4; + background: rgba(43, 27, 18, 0.86); + border-left: 7px solid #a53a30; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.home-cover-series { + color: #e4c277; + font-size: 16px; + font-weight: 800; + letter-spacing: 4px; +} + +.home-cover-title { + margin-top: 3px; + font-family: "Songti SC", "STSong", serif; + font-size: 43px; + font-weight: 900; + letter-spacing: 8px; + line-height: 1.05; +} + +.home-cover-subtitle { + margin-top: 6px; + color: #f1d18c; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.2; +} + +.home-cover-touch { + position: absolute; + z-index: 4; + right: 24px; + bottom: 22px; + display: flex; + min-height: 50px; + align-items: center; + gap: 9px; + padding: 8px 15px; + color: #fff0ca; + background: rgba(54, 34, 23, 0.9); + border: 2px solid #d7b36d; + border-radius: 999px; + font-size: 18px; + font-weight: 850; + box-shadow: 0 7px 18px rgba(0, 0, 0, 0.28); +} + +.home-cover-touch-mark { + display: flex; + width: 31px; + height: 31px; + align-items: center; + justify-content: center; + color: #6c271f; + background: #f0d28c; + border-radius: 50%; + font-size: 28px; + line-height: 1; +} + +.home-flyleaf { + display: flex; + box-sizing: border-box; + min-width: 0; + min-height: 0; + justify-content: center; + overflow: hidden; + padding: 16px 20px; + background: + repeating-linear-gradient(0deg, rgba(92, 61, 35, 0.03) 0, rgba(92, 61, 35, 0.03) 1px, transparent 1px, transparent 5px), + #f2e4bf; + flex-direction: column; +} + +.home-flyleaf-title { + display: block; + margin-top: 9px; + color: #8d2d26; + font-family: "Songti SC", "STSong", serif; + font-size: 29px; + font-weight: 900; + line-height: 1.18; +} + +.home-flyleaf .home-quote { + position: static; + display: block; + margin-top: 10px; + color: #4e3a2b; + font-size: 18px; + font-weight: 750; + line-height: 1.45; +} + +.home-flyleaf .home-primary { + margin-top: 12px; +} + +.home-flyleaf .home-tabs { + width: 100%; + min-width: 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.home-flyleaf .home-tab { + box-sizing: border-box; + width: 100%; + min-width: 0; + overflow: hidden; + padding: 5px 7px; + font-size: 16px; + white-space: nowrap; +} + +.home-comic-book button::after { + border: 0; +} + +.home-book { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(285px, 2fr); + border: 2px solid #9e845e; + border-radius: 9px; +} + +.home-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid var(--cinnabar); +} + +.home-art-image, +.home-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.home-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(35, 23, 16, 0.82), transparent 48%); +} + +.home-memory-ribbon { + position: absolute; + z-index: 3; + top: 12px; + left: 12px; + display: flex; + min-height: 48px; + align-items: center; + gap: 7px; + padding: 6px 10px 6px 7px; + color: #4a3225; + background: rgba(241, 221, 178, 0.96); + border: 2px solid #9e754a; + border-radius: 5px; + box-shadow: 0 5px 14px rgba(25, 15, 8, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + white-space: nowrap; +} + +.home-memory-ribbon-mark { + display: flex; + width: 28px; + height: 35px; + align-items: center; + justify-content: center; + color: #ffe9bb; + background: #963128; + clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 78%, 0 100%); + font-size: 14px; + font-weight: 900; +} + +.home-quote { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + color: #f5e5c0; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 20px; + font-weight: 800; + line-height: 1.5; +} + +.home-copy { + display: flex; + box-sizing: border-box; + min-width: 0; + min-height: 0; + justify-content: center; + overflow-x: hidden; + overflow-y: auto; + padding: 18px 24px; + flex-direction: column; +} + +.home-brand { + display: flex; + align-items: center; + gap: 10px; +} + +.home-brand .seal { + width: 48px; + height: 48px; + flex: none; + font-size: 27px; +} + +.home-brand-copy { + display: flex; + color: #7f2a23; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + line-height: 1.35; +} + +.home-brand-kind { + color: #765f45; + font-size: 16px; + font-weight: 700; +} + +.home-title { + display: block; + margin-top: 10px; + font-size: 45px; + font-weight: 900; + letter-spacing: 7px; + line-height: 1.05; +} + +.home-subtitle { + display: block; + margin-top: 4px; + color: #8d2d26; + font-size: 30px; + font-weight: 900; + line-height: 1.2; +} + +.home-season { + display: block; + margin-top: 5px; + color: #6f5942; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.home-primary { + display: flex; + width: 100%; + min-height: 64px; + align-items: center; + justify-content: center; + margin-top: 16px; + padding: 8px 14px; + color: #fff0cb; + background: var(--cinnabar); + border: 3px solid #74231d; + border-radius: 8px; + box-shadow: 0 8px 18px rgba(111, 32, 27, 0.24); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 900; +} + +.home-tabs { + display: grid; + gap: 9px; + margin-top: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.home-tab { + position: relative; + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 6px 10px; + color: #4b3829; + background: #ead9b2; + border: 2px solid #997b54; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.home-memory-count { + display: flex; + min-width: 30px; + height: 26px; + align-items: center; + justify-content: center; + padding: 0 6px; + color: #f9edce; + background: #704b35; + border-radius: 999px; + font-size: 13px; +} + +.home-tagline { + display: block; + margin-top: 10px; + color: #594431; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + line-height: 1.48; +} + +.home-disclaimer { + display: block; + flex: none; + color: #d7c49b; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + line-height: 1.35; + text-align: center; +} + +@media (max-height: 620px) { + .home-copy { + justify-content: flex-start; + overflow-y: hidden; + padding: 12px 18px; + } + + .home-brand .seal { + width: 42px; + height: 42px; + font-size: 24px; + } + + .home-brand-copy { + font-size: 17px; + } + + .home-brand-kind { + font-size: 15px; + } + + .home-title { + margin-top: 7px; + font-size: 39px; + } + + .home-subtitle { + font-size: 27px; + } + + .home-season { + font-size: 17px; + } + + .home-primary { + min-height: 60px; + margin-top: 11px; + font-size: 20px; + } + + .home-tabs { + margin-top: 8px; + } + + .home-tab { + min-height: 48px; + font-size: 17px; + } + + .home-tagline { + display: block; + margin-top: 6px; + font-size: 16px; + line-height: 1.35; + } + + .home-disclaimer { + font-size: 16px; + } +} + +@media (max-width: 730px) { + .home-brand-kind { + display: none; + } + + .home-brand { + max-width: calc(100% - 104px); + } + + .home-title { + font-size: 39px; + letter-spacing: 5px; + } + + .home-subtitle { + font-size: 26px; + } + + .home-tagline { + display: block; + margin-top: 6px; + font-size: 16px; + line-height: 1.35; + } + + .home-memory-ribbon { + top: 9px; + left: 9px; + min-height: 48px; + padding: 5px 8px 5px 6px; + font-size: 16px; + } +} + +@media (max-height: 430px) { + .home-comic-book.is-open { + grid-template-columns: minmax(0, 1.3fr) minmax(292px, 1fr); + } + + .home-cover-title-block { + left: 62px; + bottom: 34px; + padding: 9px 13px 10px; + } + + .home-cover-series { + font-size: 13px; + } + + .home-cover-title { + font-size: 34px; + letter-spacing: 6px; + } + + .home-cover-subtitle { + font-size: 20px; + } + + .home-cover-touch { + right: 16px; + bottom: 13px; + min-height: 48px; + font-size: 16px; + } + + .home-flyleaf { + padding: 8px 12px; + } + + .home-flyleaf .home-brand .seal { + width: 34px; + height: 34px; + font-size: 20px; + } + + .home-flyleaf .home-brand-copy { + font-size: 14px; + } + + .home-flyleaf-title { + margin-top: 5px; + font-size: 23px; + } + + .home-flyleaf .home-season { + font-size: 14px; + } + + .home-flyleaf .home-quote { + margin-top: 5px; + font-size: 15px; + line-height: 1.3; + } + + .home-flyleaf .home-primary { + min-height: 50px; + margin-top: 6px; + font-size: 18px; + } + + .home-flyleaf .home-tabs { + gap: 5px; + margin-top: 5px; + } + + .home-flyleaf .home-tab { + min-height: 48px; + padding: 4px; + font-size: 14px; + } + + .home-flyleaf .home-tagline { + margin-top: 4px; + font-size: 14px; + } + + .home-copy { + justify-content: flex-start; + overflow-y: hidden; + padding: 8px 14px; + } + + .home-brand .seal { + width: 36px; + height: 36px; + font-size: 22px; + } + + .home-brand-copy { + font-size: 15px; + } + + .home-brand-kind { + font-size: 13px; + } + + .home-title { + margin-top: 4px; + font-size: 34px; + letter-spacing: 4px; + } + + .home-subtitle { + margin-top: 2px; + font-size: 23px; + } + + .home-season { + margin-top: 3px; + font-size: 15px; + } + + .home-primary { + min-height: 60px; + margin-top: 7px; + padding: 6px 10px; + font-size: 19px; + } + + .home-tabs { + margin-top: 5px; + } + + .home-tagline { + display: block; + margin-top: 4px; + font-size: 15px; + line-height: 1.2; + } + + .home-disclaimer { + font-size: 14px; + line-height: 1.2; + } +} diff --git a/TongjiUniApp/native-adapter/tang-detective/utils/cosMedia.js b/TongjiUniApp/native-adapter/tang-detective/utils/cosMedia.js new file mode 100644 index 0000000..720a88b --- /dev/null +++ b/TongjiUniApp/native-adapter/tang-detective/utils/cosMedia.js @@ -0,0 +1,125 @@ +// Transport-only lookup. Review eligibility continues to belong to the story +// and release manifests; an HTTPS URL never confers audio approval. +const manifest = require('./cosMediaManifest') +const paths = Object.create(null) +const urls = Object.create(null) +const SHARE_ASSET_ID = 'image.tang.share-preview' + +for (const entry of manifest.entries) { + paths[entry.sourcePath] = entry + urls[entry.url] = entry +} + +function entryFor(value) { + if (typeof value !== 'string') return null + if (urls[value]) return urls[value] + let relative = value.replace(/^\//, '') + if (relative.startsWith(`${manifest.namespace}/`)) { + relative = relative.slice(manifest.namespace.length + 1) + } + return paths[relative] || null +} + +function resolve(value) { + const entry = entryFor(value) + return entry ? entry.url : '' +} + +function verifiedUrl(value, sha256, kind) { + const entry = urls[value] + return entry && entry.sha256 === sha256 && entry.kind === kind + ? entry.url : '' +} + +function mapMedia(value) { + if (typeof value === 'string') { + if (/^\/(?:[a-z0-9-]+\/)?(?:assets|package-[a-z0-9-]+)\/.*\.(?:jpe?g|png|webp|gif|svg|avif|mp3|wav|aac|m4a|ogg|mp4|webm|mov)$/i.test(value)) { + // Unregistered declarations remain text-only; never guess an object URL. + return resolve(value) + } + return value + } + if (Array.isArray(value)) return value.map(mapMedia) + if (!value || typeof value !== 'object') return value + const mapped = {} + Object.keys(value).forEach(key => { mapped[key] = mapMedia(value[key]) }) + return mapped +} + +function beginComicImage(page, pageData) { + const previous = page._cosComicImage + if (!previous || previous.pageId !== pageData.currentPageId) { + page._cosComicImage = { + pageId: pageData.currentPageId, + generation: (previous ? previous.generation : 0) + 1, + failed: Object.create(null), + lastPatch: null, + } + } + const state = page._cosComicImage + pageData.comicImageGeneration = state.generation + // Ordinary reader interactions re-render this page. They must not revive a + // URL that already failed or replace terminal text fallback with an image. + if (state.lastPatch) Object.assign(pageData, state.lastPatch) +} + +function isCurrentComicImageEvent(page, event) { + const state = page._cosComicImage + const dataset = event && event.currentTarget && event.currentTarget.dataset + return Boolean(state && dataset && !page.__tangDead && !page.__tangOriginalUnloaded + && page.__tangVisible !== false && page.data.comicImageSrc + && state.pageId === page.data.currentPageId + && dataset.cosPageId === state.pageId + && Number(dataset.cosImageGeneration) === state.generation + && dataset.cosImageSrc === page.data.comicImageSrc) +} + +function recordComicImageError(page, event) { + if (!isCurrentComicImageEvent(page, event)) return false + const state = page._cosComicImage + const src = page.data.comicImageSrc + if (state.failed[src]) return false + state.failed[src] = true + return true +} + +function canUseComicFallback(page, src) { + const state = page._cosComicImage + const entry = entryFor(src) + return Boolean(state && entry && entry.kind === 'image' && !state.failed[src]) +} + +function applyComicImageFallback(page, patch) { + if (page._cosComicImage) page._cosComicImage.lastPatch = { ...patch } + page.setData(patch) +} + +function prepareSharePreview(page, assetManager) { + if (page._sharePreviewPromise) return page._sharePreviewPromise + const generation = page.__tangShowGeneration + const alive = () => !page.__tangDead && !page.__tangOriginalUnloaded + && page.__tangVisible !== false && page.__tangShowGeneration === generation + // Resolve again for each share; the manager rehashes cached bytes before use. + const promise = Promise.resolve().then(() => assetManager.resolve(SHARE_ASSET_ID)) + .then(result => { + if (!alive()) return '' + const localPath = result && result.available && result.uri || '' + page.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + .catch(() => { + if (alive()) page.setData({ sharePreviewLocalPath: '' }) + return '' + }) + .finally(() => { + if (page._sharePreviewPromise === promise) page._sharePreviewPromise = null + }) + page._sharePreviewPromise = promise + return promise +} + +module.exports = { + SHARE_ASSET_ID, entryFor, resolve, verifiedUrl, mapMedia, prepareSharePreview, + beginComicImage, isCurrentComicImageEvent, recordComicImageError, + canUseComicFallback, applyComicImageFallback, +} diff --git a/TongjiUniApp/native-adapter/tang-detective/utils/identityHash.js b/TongjiUniApp/native-adapter/tang-detective/utils/identityHash.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native-adapter/tang-detective/utils/identityHash.js @@ -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, +} diff --git a/TongjiUniApp/native-adapter/tang-detective/utils/platformBridge.js b/TongjiUniApp/native-adapter/tang-detective/utils/platformBridge.js new file mode 100644 index 0000000..f91e422 --- /dev/null +++ b/TongjiUniApp/native-adapter/tang-detective/utils/platformBridge.js @@ -0,0 +1,3 @@ +const { createPlatformBridge } = require('./platformCore') +const config = require('./platformConfig') +module.exports = createPlatformBridge(wx, config) diff --git a/TongjiUniApp/native-adapter/tang-detective/utils/platformCore.js b/TongjiUniApp/native-adapter/tang-detective/utils/platformCore.js new file mode 100644 index 0000000..2baab07 --- /dev/null +++ b/TongjiUniApp/native-adapter/tang-detective/utils/platformCore.js @@ -0,0 +1,218 @@ +const { sha256Hex } = require('./identityHash') +const { CONTENT_VERSION, emptyProgress, projectProgress } = require('./progressContract') +const copy = value => JSON.parse(JSON.stringify(value)) +const KEY = 'tang-xuetang-v1:' + +// Dependency-injected for offline tests. Tokens remain in the existing host key +// and request header only; local slots use a SHA-256 fingerprint, not the token. +function createPlatformBridge(platform, config) { + let active = null + const listeners = new Set() + function token() { try { return String(platform.getStorageSync('token') || '') } catch (_) { return '' } } + function read(key, fallback) { try { return platform.getStorageSync(key) || fallback } catch (_) { return fallback } } + function write(key, value) { try { platform.setStorageSync(key, copy(value)); return true } catch (_) { return false } } + function context() { + const current = token() + if (active && active.token === current) return active + if (active && active.timer) clearTimeout(active.timer) + const scope = KEY + (current ? sha256Hex(current) : 'guest') + const mapped = current ? read(scope + ':user', null) : null + const key = Number.isSafeInteger(mapped) && mapped > 0 ? KEY + 'user:' + mapped : scope + const cached = read(key, {}) + active = { token: current, scope, key, verified: false, opening: null, flushing: null, timer: null, + status: current ? 'offline' : 'guest', remote: null, + state: { progress: emptyProgress(), revision: 0, story_generation: 0, user_id: null, + dirty: false, resetPending: false, serial: 0, pending: null, ...cached } } + return active + } + const current = c => active === c && token() === c.token + function notify(c, status) { + if (!current(c)) return + c.status = status + listeners.forEach(listener => { try { listener(status) } catch (_) {} }) + } + function persist(c) { + const ok = write(c.key, c.state) + if (!ok) notify(c, 'storage-error') + return ok + } + async function request(c, path, method = 'GET', data) { + if (!current(c) || !c.token) throw new Error('SESSION_CHANGED') + return new Promise((resolve, reject) => platform.request({ + url: String(config.apiBaseUrl).replace(/\/+$/, '') + '/api/tang/' + path, + method, data, timeout: 8000, + header: { token: c.token, 'content-type': 'application/json' }, + success(result) { + if (!current(c)) return reject(new Error('SESSION_CHANGED')) + const body = result.data + if (result.statusCode !== 200 || !body || typeof body !== 'object') return reject(new Error('API_UNAVAILABLE')) + if (body.code === -1) { c.verified = false; notify(c, 'auth-expired'); return reject(new Error('AUTH_EXPIRED')) } + if (body.code !== 1) { + const error = new Error(body.data && body.data.error_code || 'API_UNAVAILABLE') + return reject(error) + } + resolve(body.data) + }, fail() { reject(new Error('NETWORK_UNAVAILABLE')) }, + })) + } + function validateRemote(value) { + if (!value || value.schema_version !== 1 || value.content_version !== CONTENT_VERSION + || !Number.isSafeInteger(value.user_id) || value.user_id <= 0 + || !Number.isSafeInteger(value.revision) || value.revision < 0 + || !Number.isSafeInteger(value.story_generation) || value.story_generation < 0 + || !value.progress || typeof value.progress !== 'object') throw new Error('CONTENT_MISMATCH') + return { ...value, progress: projectProgress(value.progress) } + } + function hydrate(c, remote) { + c.state.progress = { ...c.state.progress, ...remote.progress } + c.state.revision = remote.revision + c.state.story_generation = remote.story_generation + c.state.user_id = remote.user_id + c.state.pending = null + c.state.resetPending = false + c.state.dirty = false + const saved = persist(c) + if (saved) notify(c, 'synced') + return saved + } + function failure(c, error) { + if (!current(c) || error.message === 'SESSION_CHANGED') return + if (['AUTH_EXPIRED', 'AUTH_REQUIRED'].includes(error.message)) c.verified = false + const permanent = ['INVALID_REQUEST', 'PAYLOAD_TOO_LARGE', 'IDEMPOTENCY_CONFLICT', 'UNSUPPORTED_MEDIA_TYPE', 'METHOD_NOT_ALLOWED'] + if (c.status !== 'storage-error') notify(c, ['AUTH_EXPIRED', 'AUTH_REQUIRED'].includes(error.message) ? 'auth-expired' + : ['CONTENT_MISMATCH', 'UNSUPPORTED_CONTENT_VERSION'].includes(error.message) ? 'version-error' + : permanent.includes(error.message) ? 'sync-error' : 'offline') + } + async function open(force = false) { + const c = context() + if (!c.token) return c.status + if (c.opening) return c.opening + if (c.verified && !force) return c.status + c.opening = (async () => { + c.verified = false + notify(c, 'connecting') + try { + const [catalog, raw] = await Promise.all([request(c, 'catalog'), request(c, 'progress')]) + if (!current(c)) return 'session-changed' + if (!catalog || catalog.schema_version !== 1 || catalog.content_version !== CONTENT_VERSION) throw new Error('CONTENT_MISMATCH') + const remote = validateRemote(raw) + if (c.state.user_id && c.state.user_id !== remote.user_id) throw new Error('CONTENT_MISMATCH') + // Only the authenticated server identity may select a shared user slot. + // A renewed token can recover the same user's unsynced local queue. + const userKey = KEY + 'user:' + remote.user_id + if (c.key !== userKey) { + const candidate = read(userKey, null) + const saved = candidate && typeof candidate === 'object' && !Array.isArray(candidate) ? candidate : null + if (saved && !c.state.dirty) c.state = saved + else if (saved && c.state.dirty && !write(c.scope + ':previous-user-backup', saved)) throw new Error('STORAGE_UNAVAILABLE') + c.key = userKey + if (!write(c.scope + ':user', remote.user_id)) { notify(c, 'storage-error'); return c.status } + } + c.verified = true + c.state.user_id = remote.user_id + if (!c.state.dirty) hydrate(c, remote) + else if (c.state.pending) await flushContext(c) // retry the exact idempotent request first + else if (c.state.revision !== remote.revision || c.state.story_generation !== remote.story_generation) { + c.remote = remote; notify(c, 'conflict') + } else await flushContext(c) + } catch (error) { failure(c, error) } + finally { c.opening = null } + return c.status + })() + return c.opening + } + function schedule(c) { + if (c.timer) clearTimeout(c.timer) + c.timer = setTimeout(() => { c.timer = null; flushContext(c) }, 600) + } + async function flushContext(c) { + if (!current(c) || !c.verified || !c.state.dirty || ['conflict', 'storage-error', 'sync-error', 'version-error'].includes(c.status)) return + if (c.flushing) return c.flushing + // Defer preparation so the promise is assigned before any early exit. + // The outer finally also covers a failed durable queue write. + c.flushing = Promise.resolve().then(async () => { + try { + if (!current(c)) return + if (!c.state.pending) { + const operation = c.state.resetPending ? 'reset_story' : 'replace' + const progress = projectProgress(c.state.progress) + c.state.pending = { serial: operation === 'reset_story' ? c.state.resetAt : c.state.serial, body: { + schema_version: 1, content_version: CONTENT_VERSION, + base_revision: c.state.revision, story_generation: c.state.story_generation, + request_id: 'tang-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 14), + operation, progress: operation === 'reset_story' + ? { ...emptyProgress(), collectedMemoryCards: progress.collectedMemoryCards } : progress, + } } + } + if (!persist(c)) return + const pending = copy(c.state.pending) + notify(c, 'syncing') + const remote = validateRemote(await request(c, 'saveProgress', 'POST', pending.body)) + if (!current(c)) return + if (remote.user_id !== c.state.user_id) throw new Error('CONTENT_MISMATCH') + c.state.revision = remote.revision + c.state.story_generation = remote.story_generation + c.state.pending = null + // A reset created while another request was in flight must not be lost. + if (pending.body.operation === 'reset_story' && (c.state.resetAt || 0) <= pending.serial) c.state.resetPending = false + if (pending.serial === c.state.serial) hydrate(c, remote) + else if (persist(c)) { notify(c, 'pending'); schedule(c) } + } catch (error) { + if (error.message === 'PROGRESS_CONFLICT' && current(c)) { + try { c.remote = validateRemote(await request(c, 'progress')); notify(c, 'conflict') } + catch (readError) { failure(c, readError) } + } else failure(c, error) + } + }).finally(() => { c.flushing = null }) + return c.flushing + } + function saveProgress(value, reset = false) { + const c = context() + // A delayed callback from an old page/account must not enter the new slot. + if (!value || value.__tangLocalScope !== c.scope) return false + c.state.progress = copy(value) + delete c.state.progress.__tangLocalScope + c.state.dirty = true + c.state.serial += 1 + if (reset) { c.state.resetPending = true; c.state.resetAt = c.state.serial } + if (!persist(c)) return false + if (!['conflict', 'sync-error', 'version-error', 'auth-expired'].includes(c.status)) notify(c, c.token ? 'pending' : 'guest') + if (c.verified) schedule(c) + return true + } + function getConflictContext() { + const c = context() + return c.status === 'conflict' && c.remote ? JSON.stringify([ + c.scope, c.remote.revision, c.remote.story_generation, c.state.serial, + ]) : '' + } + async function resolveConflict(choice, expectedContext) { + const c = context() + if (!expectedContext || expectedContext !== getConflictContext() || !c.verified) return false + // Recoverable, account-scoped backup before either explicit resolution. + if (!write(c.key + ':conflict-backup', { local: c.state, remote: c.remote })) { notify(c, 'storage-error'); return false } + if (choice === 'cloud') { const saved = hydrate(c, c.remote); if (saved) c.remote = null; return saved } + if (choice !== 'local') return false + c.state.revision = c.remote.revision + c.state.story_generation = c.remote.story_generation + c.state.pending = null + c.remote = null + if (!persist(c)) return false + notify(c, 'pending') + await flushContext(c) + return c.status === 'synced' + } + return { + open, saveProgress, resolveConflict, getConflictContext, + getProgress: () => { const c = context(); return { ...copy(c.state.progress || {}), ...projectProgress(c.state.progress), __tangLocalScope: c.scope } }, + getScope: () => context().scope, + getStatus: () => context().status, + flush: () => { const c = context(); if (c.timer) { clearTimeout(c.timer); c.timer = null } return flushContext(c) }, + subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener) }, + // Local preferences/audio are account-scoped and are never passed to request(). + readLocal: (suffix, fallback) => copy(read(context().key + ':' + suffix, fallback)), + writeLocal: (suffix, value) => write(context().key + ':' + suffix, value), + dispose() { if (active && active.timer) clearTimeout(active.timer); listeners.clear(); active = null }, + } +} +module.exports = { createPlatformBridge } diff --git a/TongjiUniApp/native-adapter/tang-detective/utils/progressContract.js b/TongjiUniApp/native-adapter/tang-detective/utils/progressContract.js new file mode 100644 index 0000000..35cc346 --- /dev/null +++ b/TongjiUniApp/native-adapter/tang-detective/utils/progressContract.js @@ -0,0 +1,41 @@ +// Wire projection only: never send snapshots, answers, settings or audio state. +const CONTENT_VERSION = 'season-01' +const pad = value => String(value).padStart(2, '0') +const record = value => value && typeof value === 'object' && !Array.isArray(value) ? value : {} +const list = value => Array.isArray(value) ? value : [] +function emptyProgress() { + return { completedHotspots: {}, completedChapters: [], lastChapter: 1, + collectedMemoryCards: [], comicReaderByChapter: {}, lastPageId: '' } +} +function projectProgress(input) { + const value = record(input) + const result = emptyProgress() + result.lastChapter = Number.isInteger(value.lastChapter) && value.lastChapter >= 1 && value.lastChapter <= 15 ? value.lastChapter : 1 + for (let n = 1; n <= 15; n++) { + const id = `S01-C${pad(n)}` + const saved = record(record(value.comicReaderByChapter)[id]) + const supplied = new Set(list(saved.completedEventIds || record(value.completedHotspots)[id])) + const events = [] + for (let i = 1; i <= 4; i++) { + const event = `S01-H${pad((n - 1) * 4 + i)}` + if (!supplied.has(event)) break + events.push(event) + } + const finished = events.length === 4 && (typeof saved.chapterFinished === 'boolean' + ? saved.chapterFinished : list(value.completedChapters).includes(id)) + if (Object.keys(saved).length || events.length || Object.prototype.hasOwnProperty.call(record(value.completedHotspots), id)) { + const requested = String(saved.currentPageId || (n === result.lastChapter ? value.lastPageId : '') || '') + const match = requested.match(new RegExp(`^${id}-P(0[1-8])$`)) + const page = Math.max(1, Math.min(finished ? 8 : 3 + events.length, match ? Number(match[1]) : 1)) + const currentPageId = `${id}-P${pad(page)}` + result.completedHotspots[id] = events + result.comicReaderByChapter[id] = { currentPageId, completedEventIds: events.slice(), chapterFinished: finished } + if (finished) result.completedChapters.push(id) + if (n === result.lastChapter) result.lastPageId = currentPageId + } + const card = `${id}-MC01` + if (list(value.collectedMemoryCards).includes(card)) result.collectedMemoryCards.push(card) + } + return result +} +module.exports = { CONTENT_VERSION, emptyProgress, projectProgress } diff --git a/TongjiUniApp/native-adapter/tang-detective/utils/storage.js b/TongjiUniApp/native-adapter/tang-detective/utils/storage.js new file mode 100644 index 0000000..602b522 --- /dev/null +++ b/TongjiUniApp/native-adapter/tang-detective/utils/storage.js @@ -0,0 +1,22 @@ +const bridge = require('./platformBridge') +const { emptyProgress } = require('./progressContract') +function normalizeSettings(value = {}) { + return { ...value, fontScale: value.fontScale === 'xlarge' ? 'xlarge' : 'large', sound: value.sound !== false } +} +function resetStoryProgress(expectedScope) { + if (!expectedScope || bridge.getScope() !== expectedScope) return false + const current = bridge.getProgress() + const next = { ...current, ...emptyProgress(), collectedMemoryCards: current.collectedMemoryCards || [] } + if (!bridge.saveProgress(next, true)) return false + bridge.writeLocal('audio', {}) + return true +} +module.exports = { + getProgress: bridge.getProgress, + saveProgress: value => bridge.saveProgress(value), + resetStoryProgress, + getSettings: () => normalizeSettings(bridge.readLocal('settings', {})), + saveSettings: value => bridge.writeLocal('settings', normalizeSettings(value)), + getAudioProgress: () => bridge.readLocal('audio', {}), + saveAudioProgress: value => bridge.writeLocal('audio', value), +} diff --git a/TongjiUniApp/native-adapter/tang-detective/utils/tangPage.js b/TongjiUniApp/native-adapter/tang-detective/utils/tangPage.js new file mode 100644 index 0000000..170d7af --- /dev/null +++ b/TongjiUniApp/native-adapter/tang-detective/utils/tangPage.js @@ -0,0 +1,145 @@ +// Keep the native Page lifecycle, but hydrate the correct account before any +// story page reads/writes storage. This also covers cold starts from shares. +const bridge = require('./platformBridge') +const LIFECYCLE_METHODS = new Set(['onLoad', 'onShow', 'onReady', 'onHide', 'onUnload']) +const CLEANUP_METHOD = /^(?:destroy|clear|unbind|pause|invalidate|beginPauseLock)/ +module.exports = function registerTangPage(options) { + function isCurrent(page) { + return !page.__tangDead && !page.__tangOriginalUnloaded + && page.__tangVisible && page.__tangScope === bridge.getScope() + } + function returnHome(page) { + if (page.__tangRedirected || page.__tangDead) return + page.__tangRedirected = true + wx.reLaunch({ url: '/tang-detective/pages/home/home' }) + } + function cleanUp(page, name) { + if (!page.__tangLoaded || typeof options[name] !== 'function') return + if (name !== 'onUnload' && page.__tangOriginalUnloaded) return + if (name === 'onUnload') { + if (page.__tangOriginalUnloaded) return + page.__tangOriginalUnloaded = true + } + page.__tangCleaning = true + try { + options[name].call(page) + } finally { + // This exception is synchronous only; pending timers and audio callbacks + // regain the normal visible/account/dead guards as soon as cleanup ends. + page.__tangCleaning = false + } + } + function deliverReady(page) { + if (!isCurrent(page) || !page.__tangLoaded || !page.__tangHasShown + || !page.__tangReadyRequested || page.__tangReadyDelivered) return + page.__tangReadyDelivered = true + if (typeof options.onReady === 'function') options.onReady.call(page) + } + const guarded = { ...options } + Object.keys(options).forEach(key => { + // Event handlers such as onAudioTimeUpdate are ordinary guarded methods, + // even though their names begin with "on". + if (typeof options[key] !== 'function' || LIFECYCLE_METHODS.has(key)) return + guarded[key] = function (...args) { + if (this.__tangCleaning) { + // Release resources across an account change, but do not run helpers + // such as saveCurrentAudioTime against the newly selected account. + if (this.__tangScope === bridge.getScope() || CLEANUP_METHOD.test(key)) { + return options[key].apply(this, args) + } + return + } + if (this.__tangDead || this.__tangOriginalUnloaded || !this.__tangLoaded || !this.__tangVisible) return + if (this.__tangScope && this.__tangScope !== bridge.getScope()) { + returnHome(this) + return + } + return options[key].apply(this, args) + } + }) + Page({ + ...guarded, + data: { ...(options.data || {}), tangBootPending: true }, + tangIgnoreBootTap() {}, + onLoad(query) { + this.__tangQuery = query + this.__tangScope = bridge.getScope() + this.__tangDead = false + this.__tangVisible = false + this.__tangLoaded = false + this.__tangCleaning = false + this.__tangOriginalUnloaded = false + this.__tangRedirected = false + this.__tangShowGeneration = 0 + this.__tangReadyRequested = false + this.__tangReadyDelivered = false + this.__tangHasShown = false + const nativeSetData = this.setData + this.setData = function (...args) { + if (this.__tangScope !== bridge.getScope()) return + if (!this.__tangCleaning && !isCurrent(this)) return + return nativeSetData.apply(this, args) + } + this.__tangBoot = bridge.open() + }, + onShow() { + if (this.__tangDead) return + this.__tangVisible = true + const generation = ++this.__tangShowGeneration + const scope = bridge.getScope() + if (this.__tangOriginalUnloaded || scope !== this.__tangScope) { + returnHome(this) + return + } + Promise.resolve(this.__tangBoot).then(() => { + if (this.__tangDead || !this.__tangVisible || generation !== this.__tangShowGeneration) return + if (bridge.getScope() !== scope) { + returnHome(this) + return + } + if (!this.__tangLoaded) { + this.__tangLoaded = true + if (options.onLoad) options.onLoad.call(this, this.__tangQuery) + if (!isCurrent(this)) return + this.setData({ tangBootPending: false }) + } + if (options.onShow) options.onShow.call(this) + this.__tangHasShown = true + deliverReady(this) + }).catch(() => { + if (isCurrent(this) && generation === this.__tangShowGeneration) { + wx.showToast({ title: '故事暂时未能打开,请返回重试', icon: 'none' }) + } + }) + }, + onReady() { + this.__tangReadyRequested = true + deliverReady(this) + }, + onHide() { + this.__tangVisible = false + ++this.__tangShowGeneration + try { + cleanUp(this, 'onHide') + } finally { + // A hidden page from an obsolete account must not retain contexts with + // callbacks that directly reference storage instead of guarded methods. + try { + if (this.__tangScope !== bridge.getScope()) cleanUp(this, 'onUnload') + } finally { + bridge.flush() + } + } + }, + onUnload() { + this.__tangDead = true + this.__tangVisible = false + ++this.__tangShowGeneration + try { + cleanUp(this, 'onUnload') + } finally { + bridge.flush() + } + }, + }) +} diff --git a/TongjiUniApp/native/tang-detective/app.json b/TongjiUniApp/native/tang-detective/app.json new file mode 100644 index 0000000..6cca711 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/app.json @@ -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" +} diff --git a/TongjiUniApp/native/tang-detective/app.wxss b/TongjiUniApp/native/tang-detective/app.wxss new file mode 100644 index 0000000..99126f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/app.wxss @@ -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; + } +} diff --git a/TongjiUniApp/native/tang-detective/data/cast.js b/TongjiUniApp/native/tang-detective/data/cast.js new file mode 100644 index 0000000..c53e318 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/data/cast.js @@ -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: '灰绿色开衫、白衬衫、低马尾与记录夹;不作广告式主角。', + }, +] diff --git a/TongjiUniApp/native/tang-detective/data/chapters.js b/TongjiUniApp/native/tang-detective/data/chapters.js new file mode 100644 index 0000000..c58828b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/data/chapters.js @@ -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', +})) diff --git a/TongjiUniApp/native/tang-detective/data/memoryCards.js b/TongjiUniApp/native/tang-detective/data/memoryCards.js new file mode 100644 index 0000000..c35a53b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/data/memoryCards.js @@ -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: '缺口搪瓷碗与第十五桌铜牌', + }, +] diff --git a/TongjiUniApp/native/tang-detective/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/data/productionComicPages.js new file mode 100644 index 0000000..c191ba8 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/data/productionComicPages.js @@ -0,0 +1,2067 @@ +// Generated from the reviewed C04 and C06-C15 production storyboards. 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": "" + } + ] + }, + "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": "第一桌社会客人进门,指着靠门的第十五桌:“这张给我们拼个大桌。”" + } + ] + }, + "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": "三年后的电话问:“五月初八,十桌婚宴,敢不敢接?”秦师傅看向靠墙的第十五桌。" + } + ] + }, + "S01-C08": { + "pages": [ + { + "pageId": "S01-C08-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P01-title-v1.jpg", + "caption": "第一场婚宴热闹开席,第十五桌却在掌声里被推向后厨。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MT000", + "S01-C08-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P02-opening-ensemble-v1.jpg", + "caption": "主家怕桌上不够体面,秀兰翻看复写菜单;女炊事员端着饭盒,秦师傅正挪员工桌。", + "secondaryCaption": "", + "interactionPrompt": "先看看:谁在顾客桌的体面,谁正失去坐下吃饭的位置。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MS001", + "S01-C08-MS002", + "S01-C08-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P03", + "type": "event", + "eventId": "S01-H29", + "emotionMomentId": "", + "assetName": "S01-C08-P03-H29-extra-dishes-v1.jpg", + "caption": "菜还没上齐,转盘已经没有落筷子的空;主家仍怕显得小气,隔着满桌招手加菜。", + "secondaryCaption": "", + "interactionPrompt": "只因怕被说小气,再增加几道没人需要的菜,这样妥当吗?", + "shortDialogue": "主家说:“四凉八热才像样,再添两个硬菜。”林秀兰问:“先看看十个人吃到哪儿了?”", + "hotspot": { + "x": 27, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS002", + "S01-C08-MS003", + "S01-C08-MS004", + "S01-C08-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P04", + "type": "event", + "eventId": "S01-H30", + "emotionMomentId": "", + "assetName": "S01-C08-P04-H30-rejected-box-v1.jpg", + "caption": "宾客渐散,桌上还剩半桌;主家只因觉得没面子,把女炊事员递来的打包盒推回。", + "secondaryCaption": "", + "interactionPrompt": "只因觉得打包没面子,就拒绝带走适合安全保存的剩菜,这样妥当吗?", + "shortDialogue": "主家说:“喜事哪能拎剩菜走。”女炊事员答:“先问哪些能带,别让面子替食物做决定。”", + "hotspot": { + "x": 28, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P05", + "type": "event", + "eventId": "S01-H31", + "emotionMomentId": "", + "assetName": "S01-C08-P05-H31-menu-gaps-v1.jpg", + "caption": "秀兰把复写菜单翻过来,纸上只有菜名,没有人数,也没有一盘大约够几个人。", + "secondaryCaption": "", + "interactionPrompt": "套餐只列菜名,不说明大致份量和供几人,这样妥当吗?", + "shortDialogue": "林秀兰说:“光有菜名,不知道多大盘,客人怎么知道够不够?”", + "hotspot": { + "x": 22, + "y": 0, + "w": 61, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS008", + "S01-C08-MS009", + "S01-C08-MS010" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P06", + "type": "event", + "eventId": "S01-H32", + "emotionMomentId": "", + "assetName": "S01-C08-P06-H32-removed-plaque-v1.jpg", + "caption": "客桌加了,员工的座位却没了。秦师傅说:“就借这一晚。”", + "secondaryCaption": "", + "interactionPrompt": "为临时加桌,让员工整晚端碗站着吃,并说“就借这一晚”,这样妥当吗?", + "shortDialogue": "秦师傅说:“头一场席,就借一晚。”林秀兰停了一拍:“我也说过,就这一场。”", + "hotspot": { + "x": 23, + "y": 5, + "w": 44, + "h": 90 + }, + "audioCueIds": [ + "S01-C08-MS006", + "S01-C08-MS007", + "S01-C08-MS012", + "S01-C08-MS013" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM08", + "assetName": "S01-C08-P07-EM08-back-kitchen-light-v1.jpg", + "caption": "婚宴笑声还没散,女炊事员端着饭盒站在传菜口,一时找不到放碗的位置。", + "secondaryCaption": "", + "interactionPrompt": "在最忙的时候,你想怎样让她知道自己没有被忘记?", + "shortDialogue": "", + "hotspot": { + "x": 27, + "y": 0, + "w": 43, + "h": 100 + }, + "audioCueIds": [ + "S01-C08-MS006", + "S01-C08-MS007", + "S01-C08-TE900" + ], + "tableEcho": "热闹照得到客人,也该照得到端菜的人。", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg", + "caption": "热闹照得到客人,也该照得到端菜的人。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MS012", + "S01-C08-MS013", + "S01-C08-TE900", + "S01-C08-MS014" + ], + "tableEcho": "热闹照得到客人,也该照得到端菜的人。", + "cliffhanger": "现金盒合上前,铜牌背面的暗红漆与两张破损饭票一闪而过;下一场订席电话又响了。" + } + ] + }, + "S01-C09": { + "pages": [ + { + "pageId": "S01-C09-P01", + "type": "chapter-title-and-opening-memory", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P01-opening-memory-v1.jpg", + "caption": "医院门刚合上,第一次聚餐,一盘菜从赵伯面前悄悄挪远。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "programDetailInset": { + "label": "桌边近景", + "x": 69, + "y": 51, + "w": 31, + "h": 35, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C09-MT000", + "S01-C09-MOM001", + "S01-C09-MOM002", + "S01-C09-MOM003", + "S01-C09-MTRANS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P02-opening-ensemble-v1.jpg", + "caption": "第一圈酒绕来,赵伯坐在原位看着;老周抬起手,唐守安摸向空杯,明远的酒壶停在桌中。", + "secondaryCaption": "", + "interactionPrompt": "先看看:谁在说自己的选择,谁还在替别人决定。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C09-MTRANS001", + "S01-C09-MS004", + "S01-C09-MS006", + "S01-C09-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P03", + "type": "event", + "eventId": "S01-H33", + "emotionMomentId": "", + "assetName": "S01-C09-P03-H33-driver-no-alcohol-v1.jpg", + "caption": "老周把车钥匙压在桌上,白酒仍被推来;他用整只手挡住杯子,一口也不碰。", + "secondaryCaption": "", + "interactionPrompt": "对要开车的人说“就一小口没事”,这样妥当吗?", + "shortDialogue": "老周按住钥匙:“车是我开,一口也不碰。”林秀兰把白水换到他面前。", + "hotspot": { + "x": 20, + "y": 0, + "w": 65, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS002", + "S01-C09-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P04", + "type": "event", + "eventId": "S01-H34", + "emotionMomentId": "", + "assetName": "S01-C09-P04-H34-tea-toast-v1.jpg", + "caption": "唐守安坐在靠门末席,轻盖空酒杯,端起茶;桌上的笑声一停,没人再把酒推回来。", + "secondaryCaption": "", + "interactionPrompt": "清楚拒绝饮酒,其他人也不继续起哄,这样更稳妥吗?", + "shortDialogue": "唐守安说:“心意我领,酒不喝。咱们拿茶照样碰。”", + "hotspot": { + "x": 21, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS006", + "S01-C09-MS007", + "S01-C09-MS009", + "S01-C09-MS013" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P05", + "type": "event", + "eventId": "S01-H35", + "emotionMomentId": "", + "assetName": "S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg", + "caption": "赵伯把自己的闭合随身药盒推向一旁,想为这场酒局把原来的安排往后挪。唐守安先请他停一下。", + "secondaryCaption": "涉及用药调整,应按专业人员确认的个人方案处理;不要自行停药或改量", + "outsideActionLabel": "打开赵伯这一刻", + "artTapHint": "画中赵伯也能点", + "interactionPrompt": "为了参加酒局,自行停药、补服、加倍、减量或调整胰岛素,这样妥当吗?", + "shortDialogue": "赵伯说:“今天喝酒,药先挪一挪?”唐守安摇头:“别在饭桌上自己改,先按你的方案办。”", + "hotspot": { + "x": 18, + "y": 0, + "w": 68, + "h": 99 + }, + "programDetailInset": { + "label": "手边近景", + "x": 8, + "y": 42, + "w": 50, + "h": 56, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C09-MS010", + "S01-C09-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P06", + "type": "event", + "eventId": "S01-H36", + "emotionMomentId": "", + "assetName": "S01-C09-P06-H36-stop-and-stay-v1.jpg", + "caption": "第三圈还没走完,赵伯出汗、手抖、回答变慢;唐守安先停酒、移开杯子,只留人陪在近处。", + "secondaryCaption": "", + "interactionPrompt": "发现出汗、手抖、反应变慢后,先停酒、移开危险物并留下人陪伴,这样更稳妥吗?", + "shortDialogue": "唐守安说:“先停酒,别让他一个人。能不能安全吞咽先看清,严重时马上联系急救。”", + "hotspot": { + "x": 22, + "y": 0, + "w": 62, + "h": 99 + }, + "programDetailInset": { + "label": "手边近景", + "x": 35, + "y": 42, + "w": 36, + "h": 39, + "anchor": "top-right" + }, + "audioCueIds": [ + "S01-C09-MS014", + "S01-C09-MS015", + "S01-C09-MS016" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM09", + "assetName": "S01-C09-P07-EM09-keep-my-seat-v1.jpg", + "caption": "酒杯停下后,赵伯仍坐在老位置。把座位留着,等他自己说愿不愿意坐下。", + "secondaryCaption": "", + "interactionPrompt": "你愿意怎样让赵伯先说出自己的担心?", + "shortDialogue": "", + "hotspot": { + "x": 24, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS004", + "S01-C09-MS005", + "S01-C09-MS007", + "S01-C09-MS008", + "S01-C09-MS009", + "S01-C09-TE900" + ], + "tableEcho": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg", + "caption": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C09-MS017", + "S01-C09-MS018", + "S01-C09-TE900", + "S01-C09-MS019" + ], + "tableEcho": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "memoryDisplayLines": [ + "陪他把选择说出来。", + "今天:开车就换茶或白水。", + "回家聊:先听赵伯把担心说完。" + ], + "cliffhanger": "赵伯推远酒盅,人和饭仍留在桌边;门外开始敲隔断。" + } + ] + }, + "S01-C10": { + "pages": [ + { + "pageId": "S01-C10-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P01-one-palm-space-v1.jpg", + "caption": "旧桌留在后厨,女炊事员端碗回来,桌上只剩一掌空。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-MT000", + "S01-C10-MS001", + "S01-C10-MS002" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P02-opening-ensemble-v1.jpg", + "caption": "端碗的、添饭的、接电话的、端饭盒的,全站在一张不能坐的桌边。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁想坐下吃一顿完整的饭,谁又被别的事情拉走。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-MS003", + "S01-C10-MS005", + "S01-C10-MS006", + "S01-C10-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P03", + "type": "event", + "eventId": "S01-H37", + "emotionMomentId": "", + "assetName": "S01-C10-P03-H37-standing-meal-v1.jpg", + "caption": "女炊事员趁出菜空隙站着扒饭,脚边没有凳子,桌上也找不到落碗处。", + "secondaryCaption": "", + "interactionPrompt": "每天都在出菜间隙快速站着吃完,这样妥当吗?", + "shortDialogue": "女炊事员笑说:“站着吃快。”明远看着空不了的桌:“快,不等于每天都该这样。”", + "hotspot": { + "x": 21, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS001", + "S01-C10-MS002", + "S01-C10-MS003", + "S01-C10-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P04", + "type": "event", + "eventId": "S01-H38", + "emotionMomentId": "", + "assetName": "S01-C10-P04-H38-open-palm-v3.jpg", + "caption": "赵伯端着大碗、握着添饭勺;明远伸出手掌,清楚说自己已经饱了。", + "secondaryCaption": "", + "outsideActionLabel": "打开赵伯这一刻", + "artTapHint": "画中赵伯也能点", + "interactionPrompt": "用碗大、吃得多证明年轻男性有本事,这样妥当吗?", + "shortDialogue": "赵伯举起大碗:“男人吃饭,碗小了哪顶事?”明远摊开手掌:“赵叔,我已经饱了。”", + "hotspot": { + "x": 17, + "y": 0, + "w": 69, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS008", + "S01-C10-MS009", + "S01-C10-MS010", + "S01-C10-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P05", + "type": "event", + "eventId": "S01-H39", + "emotionMomentId": "", + "assetName": "S01-C10-P05-H39-phone-and-cold-meal-v1.jpg", + "caption": "长卷线座机又响,明远半坐半起去接;冷饭不再冒热气,墙钟早过了饭点。", + "secondaryCaption": "", + "interactionPrompt": "连续久坐接订席,正餐一拖再拖,这样妥当吗?", + "shortDialogue": "明远说:“接完这一个就吃。”电话那头又来一桌,他的筷子再次放下。", + "hotspot": { + "x": 18, + "y": 0, + "w": 70, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS003", + "S01-C10-MS004", + "S01-C10-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P06", + "type": "event", + "eventId": "S01-H40", + "emotionMomentId": "", + "assetName": "S01-C10-P06-H40-table-not-usable-v1.jpg", + "caption": "秦师傅说桌一直给自己人留着,可菜筐、酒箱和账本压满桌面,他的饭盒也无处放。", + "secondaryCaption": "", + "interactionPrompt": "口头说“给自己人留桌”,实际长期堆物不能坐,这样妥当吗?", + "shortDialogue": "秦师傅说:“这桌一直留着呢。”女炊事员指指酒箱:“留给谁了,酒瓶?”", + "hotspot": { + "x": 20, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS006", + "S01-C10-MS007", + "S01-C10-MS014" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM10", + "assetName": "S01-C10-P07-EM10-put-meal-down-v1.jpg", + "caption": "座机又响,明远一手拿听筒,一手端冷饭盒,桌上没有落碗处。", + "secondaryCaption": "", + "interactionPrompt": "你想怎样帮他把这一顿饭重新放回生活里?", + "shortDialogue": "", + "hotspot": { + "x": 20, + "y": 0, + "w": 66, + "h": 99 + }, + "programDetailInset": { + "label": "饭盒近景", + "x": 44, + "y": 45, + "w": 33, + "h": 37, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C10-MS005", + "S01-C10-MS012", + "S01-C10-MS013", + "S01-C10-TE900" + ], + "tableEcho": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P08-plan-line-last-stool-v1.jpg", + "caption": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-TE900", + "S01-C10-MS014" + ], + "tableEcho": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "cliffhanger": "规划红线压过桌脚,最后一张凳子被推走。字幕写着:“五年后,这张图再次展开。”" + } + ] + }, + "S01-C11": { + "pages": [ + { + "pageId": "S01-C11-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P01-pen-above-paper-v1.jpg", + "caption": "旧图再次展开,秦师傅握着笔,先看旧桌,再看等他签字的人。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MT000", + "S01-C11-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P02-opening-ensemble-v1.jpg", + "caption": "小满擦展柜,秦师傅悬笔,施工负责人分通道;林秀兰与唐守安都在等他的决定。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在保存,谁在检查,谁在让路,谁又握着最后要落的笔。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MS002", + "S01-C11-MS003", + "S01-C11-MS004", + "S01-C11-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P03", + "type": "event", + "eventId": "S01-H41", + "emotionMomentId": "", + "assetName": "S01-C11-P03-H41-display-and-seat-v1.jpg", + "caption": "小满把玻璃与展板擦亮,回头才看见旧长凳正被搬走,林秀兰还在等她看那块空位。", + "secondaryCaption": "", + "interactionPrompt": "只把“健康、互助”做成展板,实际服务没有变化,这样妥当吗?", + "shortDialogue": "小满说:“这些字要留下。”林秀兰回她:“字留下了,人坐哪儿,也得留下。”", + "hotspot": { + "x": 20, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS002", + "S01-C11-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P04", + "type": "event", + "eventId": "S01-H42", + "emotionMomentId": "", + "assetName": "S01-C11-P04-H42-inspect-old-table-v1.jpg", + "caption": "秦师傅摸着旧桌沿想直接搬走,卷尺和检查表已递到手边,松动桌腿还没有人作结论。", + "secondaryCaption": "", + "interactionPrompt": "不检查结构安全,因怀旧就直接继续给客人使用,这样妥当吗?", + "shortDialogue": "秦师傅摸着桌沿:“它撑了几十年。”施工负责人说:“先查清还能不能安全撑今天。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS001", + "S01-C11-MS006", + "S01-C11-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P05", + "type": "event", + "eventId": "S01-H43", + "emotionMomentId": "", + "assetName": "S01-C11-P05-H43-clear-passage-v1.jpg", + "caption": "施工负责人亲手合上材料区围挡,又把通道口让开;唐守安和员工抬着长凳顺利通过。", + "secondaryCaption": "", + "interactionPrompt": "施工材料与人员必经路线分开,并保留清楚通道,这样更稳妥吗?", + "shortDialogue": "施工负责人说:“人走这一边,材料走那一边。看得见的通道,才真能走。”", + "hotspot": { + "x": 18, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS004", + "S01-C11-MS005", + "S01-C11-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P06", + "type": "event", + "eventId": "S01-H44", + "emotionMomentId": "", + "assetName": "S01-C11-P06-H44-pen-before-signature-v1.jpg", + "caption": "秦师傅把旧物清单又看一遍,笔尖终于落下半寸;林秀兰站在侧后,没有替他拿笔。", + "secondaryCaption": "", + "interactionPrompt": "白天签下旧物处置后,夜里仍保存完整桌板、铜牌与钥匙记录,这样更稳妥吗?", + "shortDialogue": "林秀兰问:“你不是说这桌不动吗?”秦师傅说:“饭店得活下来,人才能回来。”", + "hotspot": { + "x": 20, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS006", + "S01-C11-MS007", + "S01-C11-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM11", + "assetName": "S01-C11-P07-EM11-wrap-and-record-v1.jpg", + "caption": "夜里,秦师傅把桌板、铜牌和两枚破票逐件包好;空白记录还等他留下些什么。", + "secondaryCaption": "", + "interactionPrompt": "除了保存旧物,你想请秦师傅再留下些什么?", + "shortDialogue": "", + "hotspot": { + "x": 21, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS009", + "S01-C11-MS010", + "S01-C11-MS011", + "S01-C11-TE900" + ], + "tableEcho": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P08-can-we-open-now-v1.jpg", + "caption": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MS012", + "S01-C11-MS013", + "S01-C11-MS014" + ], + "tableEcho": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "cliffhanger": "十八年后,小满认出抽屉里的旧饭票夹。她没有擅自拿,只第三次问:“现在能开吗?”" + } + ] + }, + "S01-C12": { + "pages": [ + { + "pageId": "S01-C12-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P01-key-pauses-half-turn-v1.jpg", + "caption": "爷爷点了头,小满才取下饭票夹;钥匙转到一半,她又停下来说明。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MT000", + "S01-C12-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P02-modern-table-ensemble-v1.jpg", + "caption": "旧桌板被人擦亮,纸单被人铺开;赵伯按加号,明远拿起信息卡,秦师傅仍站在后厨门里。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在核对旧物,谁把点餐方式摆出来,谁又想替一家人一次安排完。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MS002", + "S01-C12-MS003", + "S01-C12-MS005", + "S01-C12-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P03", + "type": "event", + "eventId": "S01-H45", + "emotionMomentId": "", + "assetName": "S01-C12-P03-H45-provenance-chain-v1.jpg", + "caption": "小满先拍桌板全貌,再拍红漆、孔位和票据;林秀兰擦板,秦师傅把纸筒递到手边。", + "secondaryCaption": "", + "interactionPrompt": "征得秦师傅同意后开柜,并用红漆、孔位、板字、照片和票据共同核对,这样更稳妥吗?", + "shortDialogue": "小满说:“这回能开吗?”秦师傅点头。她又说:“一件一件记,别让一个孔替所有证据说话。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS001", + "S01-C12-MS002", + "S01-C12-MS003", + "S01-C12-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P04", + "type": "event", + "eventId": "S01-H46", + "emotionMomentId": "", + "assetName": "S01-C12-P04-H46-real-choice-counter-v1.jpg", + "caption": "小满把大字纸单推向老人,员工当面等他开口;扫码牌在旁,现金盒也正常打开。", + "secondaryCaption": "", + "interactionPrompt": "在扫码之外保留大字纸单、人工点餐,并保持正常现金收款,这样更稳妥吗?", + "shortDialogue": "小满说:“会扫码就扫,想看纸单就看纸单;有人在这儿帮,现金也照常收。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS005", + "S01-C12-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P05", + "type": "event", + "eventId": "S01-H47", + "emotionMomentId": "", + "assetName": "S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg", + "caption": "十个人围着真实饭桌,赵伯熟练按下加号;乐乐只按住自己的手,先问每份多大。", + "secondaryCaption": "", + "interactionPrompt": "因“七十周年不能空”继续加菜,这样妥当吗?", + "shortDialogue": "赵伯说:“十四不好听,再来俩。”乐乐按住减号:“十个人十四道,已经不是数学问题了。”", + "hotspot": { + "x": 15, + "y": 0, + "w": 69, + "h": 99 + }, + "programEvidenceInset": { + "kind": "order-count", + "kicker": "画中近景", + "screenLabel": "手机点单", + "countLabel": "已点", + "countValue": 14, + "countUnit": "道", + "plusGlyph": "+", + "note": "赵伯还在加菜", + "anchor": "top-right", + "ariaLabel": "画中近景:手机显示已点十四道,赵伯还要按加号继续加菜。" + }, + "audioCueIds": [ + "S01-C12-MS007", + "S01-C12-MS008", + "S01-C12-MS009" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P06", + "type": "event", + "eventId": "S01-H48", + "emotionMomentId": "", + "assetName": "S01-C12-P06-H48-card-is-not-a-promise-v1.jpg", + "caption": "明远把信息卡往父亲面前一放,像找到了省心答案;乐乐却指着空白栏,等他把话听完。", + "secondaryCaption": "", + "interactionPrompt": "看到“烹调方式:炖;本份约供2—3人;可选小份”,就理解成“健康保证”,这样妥当吗?", + "shortDialogue": "明远说:“写得这么健康,多点一份没事。”乐乐问:“它说怎么做、多大份,又没说能治病吧?”", + "hotspot": { + "x": 18, + "y": 0, + "w": 67, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS010", + "S01-C12-MS011", + "S01-C12-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM12", + "assetName": "S01-C12-P07-EM12-hand-choice-back-v1.jpg", + "caption": "手机、语音、纸单和服务员都在;乐乐把纸单放到赵伯手边,明远终于停下来等他开口。", + "secondaryCaption": "", + "interactionPrompt": "信息都在了,最后一步怎样交还给赵伯?", + "shortDialogue": "", + "hotspot": { + "x": 18, + "y": 0, + "w": 67, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS005", + "S01-C12-MS006", + "S01-C12-TE900" + ], + "tableEcho": "让人看懂、让人开口,才算把选择递到手里。", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P08-rolled-speech-cliffhanger-v1.jpg", + "caption": "让人看懂、让人开口,才算把选择递到手里。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MS013", + "S01-C12-MS014-ATTR", + "S01-C12-MS014", + "S01-C12-MS015", + "S01-C12-MS016" + ], + "tableEcho": "让人看懂、让人开口,才算把选择递到手里。", + "cliffhanger": "秦师傅把稿子重新卷起,只说:“先上菜。”林秀兰看见末行写着“请晚班的人原谅我”。" + } + ] + }, + "S01-C13": { + "pages": [ + { + "pageId": "S01-C13-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P01-three-chopsticks-collide-v1.jpg", + "caption": "乐乐刚说饱,三双公筷却同时伸来;只听“叮”的一声,桌边静了半拍。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MT000", + "S01-C13-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P02-opening-ensemble-v1.jpg", + "caption": "乐乐护碗,明远推盘,赵伯的姓名牌去了侧桌;小满手里的软尺,还没有展开。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁还在替人夹,谁把自己的菜推过去,谁又让姓名牌先离了席。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MS002", + "S01-C13-MS003", + "S01-C13-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P03", + "type": "event", + "eventId": "S01-H49", + "emotionMomentId": "", + "assetName": "S01-C13-P03-H49-ask-before-serving-v1.jpg", + "caption": "三位长辈都想照顾孩子,却没有一个先问;公筷干净,也不能替乐乐说“要”。", + "secondaryCaption": "", + "interactionPrompt": "不问乐乐就轮流替他夹菜,这样妥当吗?", + "shortDialogue": "赵伯笑:“还排上号了。”乐乐护住碗:“公筷也是筷子,先问我呀!”", + "hotspot": { + "x": 14, + "y": 3, + "w": 70, + "h": 94 + }, + "audioCueIds": [ + "S01-C13-MS001", + "S01-C13-MS002", + "S01-C13-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P04", + "type": "event", + "eventId": "S01-H50", + "emotionMomentId": "", + "assetName": "S01-C13-P04-H50-name-card-leaves-first-v1.jpg", + "caption": "小满想把照顾安排周全,却先把姓名牌移去侧桌;赵伯本人仍坐在大家中间。", + "secondaryCaption": "", + "interactionPrompt": "因担心赵伯,未经商量就把他安排到隔开的桌上,这样妥当吗?", + "shortDialogue": "赵伯探身说:“我人还在这儿,牌先被请走了?”小满的手停在半空。", + "hotspot": { + "x": 43, + "y": 1, + "w": 54, + "h": 98 + }, + "programDetailInset": { + "label": "桌边近景", + "x": 65, + "y": 36, + "w": 35, + "h": 39, + "anchor": "top-right", + "labelAnchor": "bottom-left" + }, + "audioCueIds": [ + "S01-C13-MS006", + "S01-C13-MS007", + "S01-C13-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P05", + "type": "event", + "eventId": "S01-H51", + "emotionMomentId": "", + "assetName": "S01-C13-P05-H51-family-double-standard-v1.jpg", + "caption": "明远把自己的甜饮举到嘴边,另一只手还搭在推给乐乐的菜盘上;乐乐抬眼看他。", + "secondaryCaption": "", + "interactionPrompt": "大人提醒孩子少喝,自己却仍举着甜饮,这样妥当吗?", + "shortDialogue": "明远说:“饮料少喝。”乐乐看着他的瓶子:“爸爸,那咱们一起少喝,好吗?”", + "hotspot": { + "x": 18, + "y": 0, + "w": 68, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS004", + "S01-C13-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P06", + "type": "event", + "eventId": "S01-H52", + "emotionMomentId": "", + "assetName": "S01-C13-P06-H52-tape-stays-rolled-v1.jpg", + "caption": "小满看着姓名牌和卷起的软尺,没有先量侧桌;这一回,她决定先问坐桌的人。", + "secondaryCaption": "", + "interactionPrompt": "不先挪人,先问赵伯想坐哪里,再调整共同桌,这样更稳妥吗?", + "shortDialogue": "小满问:“赵伯,您想坐哪儿?”赵伯把椅子往里收了收:“我还坐大家旁边。”", + "hotspot": { + "x": 24, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS009", + "S01-C13-MS010", + "S01-C13-MS011", + "S01-C13-MS012", + "S01-C13-MS013", + "S01-C13-MS014", + "S01-C13-MS015" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM13", + "assetName": "S01-C13-P07-EM13-listen-to-child-v1.jpg", + "caption": "三双公筷终于都停下。乐乐还护着碗,等大人第一次不替他说话。", + "secondaryCaption": "", + "interactionPrompt": "这一回,家里人怎样让乐乐自己选?", + "shortDialogue": "", + "hotspot": { + "x": 24, + "y": 0, + "w": 54, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS010", + "S01-C13-MS011", + "S01-C13-MS012", + "S01-C13-TE900" + ], + "tableEcho": "这一次,大人终于听孩子把话说完。", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P08", + "type": "memory-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P08-cook-crosses-threshold-v1.jpg", + "caption": "为你好之前,先听一句“我要不要”;照顾也要把选择还给人。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MS016", + "S01-C13-MS017", + "S01-C13-MS018" + ], + "tableEcho": "", + "cliffhanger": "秦师傅端起一锅白菜热汤面,终于跨过躲了整晚的后厨门槛。" + } + ] + }, + "S01-C14": { + "pages": [ + { + "pageId": "S01-C14-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P01-lid-opens-old-scent-v1.jpg", + "caption": "锅盖一揭,白菜热汤面冒起白汽;赵伯一句“就这”,忽然停在了半截。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MT000", + "S01-C14-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P02-responsibility-ensemble-v1.jpg", + "caption": "秦师傅扶住旧板,林秀兰排开证据;唐守安停在门框,赵伯从普通座端茶起身。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在说自己的责任,谁只补证据,谁没有往主位走,谁仍在桌上举杯。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MS002", + "S01-C14-MS003", + "S01-C14-MS004", + "S01-C14-MS005", + "S01-C14-MS006", + "S01-C14-MS007-ATTR", + "S01-C14-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P03", + "type": "event", + "eventId": "S01-H53", + "emotionMomentId": "", + "assetName": "S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg", + "caption": "秦师傅只讲白菜、面、热汤、做法与份量;旧味能留人情,不能替代治疗。", + "secondaryCaption": "", + "interactionPrompt": "因为是老菜,就宣传成“祖传降糖面”,这样妥当吗?", + "shortDialogue": "秦师傅说:“就是白菜、面和一锅热汤。讲做法、讲份量,别给它封神。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 68, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS001", + "S01-C14-MS002", + "S01-C14-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P04", + "type": "event", + "eventId": "S01-H54", + "emotionMomentId": "", + "assetName": "S01-C14-P04-H54-evidence-chain-v1.jpg", + "caption": "林秀兰把两枚破票、铜牌、红漆、孔位、板字与照片逐项核对;没有一件物证独自定案。", + "secondaryCaption": "", + "interactionPrompt": "把票据、铜牌、暗红漆、孔位、板字和照片一起核对,这样更稳妥吗?", + "shortDialogue": "林秀兰说:“每件东西只说自己知道的那一段,合起来,故事才站得稳。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 71, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS004", + "S01-C14-MS007-ATTR", + "S01-C14-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P05", + "type": "event", + "eventId": "S01-H55", + "emotionMomentId": "", + "assetName": "S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg", + "caption": "唐守安停在门框,没有去主位;赵伯拉开身边的普通座,秦师傅的话仍由秦师傅说完。", + "secondaryCaption": "", + "interactionPrompt": "大家请他坐主位,他选择普通座并让秦师傅自己说完,这样更稳妥吗?", + "shortDialogue": "赵伯喊:“给唐大夫留个座。”唐守安摆手:“普通座就好,先听老秦把话说完。”", + "hotspot": { + "x": 48, + "y": 1, + "w": 48, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS008", + "S01-C14-MS009", + "S01-C14-MS010" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P06", + "type": "event", + "eventId": "S01-H56", + "emotionMomentId": "", + "assetName": "S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg", + "caption": "赵伯把蓝花盖碗转半圈,主动同大家碰杯;不用酒证明能耐,他也没有离开这张桌。", + "secondaryCaption": "", + "interactionPrompt": "用茶参加碰杯,也不再要求别人喝酒,这样更稳妥吗?", + "shortDialogue": "赵伯说:“这回我当个不劝酒、不硬撑的骨干。老伙计,茶也算一杯。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 65, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS011", + "S01-C14-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM14", + "assetName": "S01-C14-P07-EM14-watch-face-down-v1.jpg", + "caption": "父子同高坐下,那只曾让问话匆匆结束的手表,还亮在两人之间。", + "secondaryCaption": "", + "interactionPrompt": "唐守安怎样让儿子把那句旧话真正说完?", + "shortDialogue": "", + "hotspot": { + "x": 8, + "y": 1, + "w": 84, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS012", + "S01-C14-MS013", + "S01-C14-MS014", + "S01-C14-MS015", + "S01-C14-TE900" + ], + "tableEcho": "会解决问题的人,也可以学着先听一会儿。", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P08", + "type": "memory-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P08-measure-the-empty-place-v1.jpg", + "caption": "会解决问题,也要先听一会儿;桌子能否回来,先看那块空位最终坐回谁。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MS016", + "S01-C14-MS017" + ], + "tableEcho": "", + "cliffhanger": "秦师傅问:“十五桌还能回来吗?”小满没有回答,先拿软尺重新量空位。" + } + ] + }, + "S01-C15": { + "pages": [ + { + "pageId": "S01-C15-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P01-chair-opens-for-late-workers-v1.jpg", + "caption": "三周后,晚市将收;小满先拉开椅子,门口那几个人终于不用再等。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C15-MT000", + "S01-C15-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P02-everyone-makes-room-ensemble-v1.jpg", + "caption": "小满把椅子朝共同桌拉开,赵伯抬手招呼;两位晚班工友走进门,来晚的人也有位置。", + "secondaryCaption": "", + "interactionPrompt": "看看小满把椅子拉向哪里,再看门口的人正往哪里走。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C15-MS002", + "S01-C15-MS003", + "S01-C15-MS004", + "S01-C15-MS005", + "S01-C15-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P03", + "type": "event", + "eventId": "S01-H57", + "emotionMomentId": "", + "assetName": "S01-C15-P03-H57-three-real-portions-v1.jpg", + "caption": "小甄先看来了几个人,再把普通份、小份、半份三种真实餐盘推到大家眼前。", + "secondaryCaption": "", + "interactionPrompt": "菜单提供多种份量并写建议用餐人数,这样更稳妥吗?", + "shortDialogue": "小甄说:“先看几个人、每份多大。少点不够再添,比猜一桌需要多少更踏实。”", + "hotspot": { + "x": 17, + "y": 1, + "w": 55, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS003", + "S01-C15-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P04", + "type": "event", + "eventId": "S01-H58", + "emotionMomentId": "", + "assetName": "S01-C15-P04-H58-water-within-reach-v1.jpg", + "caption": "小甄把白水壶放到共同桌边;其他饮品仍在侧柜,先问本人,再决定要不要。", + "secondaryCaption": "", + "interactionPrompt": "默认让白水容易取得,酒和甜饮由本人选择,不替所有人自动摆上,这样更稳妥吗?", + "shortDialogue": "小甄说:“白水先放手边,其他饮品看清信息、问过本人,再决定要不要。”", + "hotspot": { + "x": 41, + "y": 1, + "w": 49, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS003", + "S01-C15-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P05", + "type": "event", + "eventId": "S01-H59", + "emotionMomentId": "", + "assetName": "S01-C15-P05-H59-ask-before-serving-v1.jpg", + "caption": "明远的公筷停在乐乐碗外。他先看孩子的脸,再问:“这个还要吗?”", + "secondaryCaption": "", + "interactionPrompt": "夹菜前先问乐乐还要不要,这样更稳妥吗?", + "shortDialogue": "明远问:“这个还要吗?”乐乐点头:“要一点,我自己夹。”明远把公筷递过去。", + "hotspot": { + "x": 12, + "y": 1, + "w": 58, + "h": 98 + }, + "programDetailInset": { + "label": "公筷停住", + "x": 22, + "y": 38, + "w": 51, + "h": 57, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C15-MS007", + "S01-C15-MS008", + "S01-C15-MS009", + "S01-C15-MS010", + "S01-C15-MS011", + "S01-C15-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P06", + "type": "event", + "eventId": "S01-H60", + "emotionMomentId": "", + "assetName": "S01-C15-P06-H60-check-before-packing-v1.jpg", + "caption": "员工没有把剩菜一股脑装盒;她逐盘询问,也把保存和再加热提示一项项说明。", + "secondaryCaption": "", + "interactionPrompt": "所有剩菜不分情况都必须打包,这样妥当吗?", + "shortDialogue": "员工问:“这些要带走吗?我先把能不能保存、怎么再加热给您说清。”", + "hotspot": { + "x": 31, + "y": 1, + "w": 58, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS016" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM15", + "assetName": "S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg", + "caption": "同一机位里,人已坐满;老吕带来的右缺口蓝边旧碗,还是空的。", + "secondaryCaption": "", + "interactionPrompt": "这只旧碗怎样留在今天的第十五桌边?", + "shortDialogue": "", + "hotspot": { + "x": 12, + "y": 1, + "w": 76, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS013", + "S01-C15-MS014", + "S01-C15-MS015", + "S01-C15-MS016", + "S01-C15-TE900", + "S01-C15-MS017" + ], + "tableEcho": "桌子回来了,人也都坐回来了。", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P08", + "type": "memory-season-close", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P08-people-seated-season-close-v2.jpg", + "caption": "第一回少的桌,如今有人坐回来了。铜牌回到桌沿,晚班工友也坐回来了。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "programDetailInset": { + "label": "秦师傅", + "x": 72, + "y": 7, + "w": 25, + "h": 44, + "anchor": "top-right" + }, + "audioCueIds": [ + "S01-C15-MS016", + "S01-C15-TE900", + "S01-C15-MS017", + "S01-C15-MS018" + ], + "tableEcho": "", + "cliffhanger": "快门按下,旧铜牌“15”重新固定。原来缺的不是一张桌,是这些人应该有的位置。" + } + ] + } +} diff --git a/TongjiUniApp/native/tang-detective/data/releaseInfo.js b/TongjiUniApp/native/tang-detective/data/releaseInfo.js new file mode 100644 index 0000000..600fe6b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/data/releaseInfo.js @@ -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', +}) diff --git a/TongjiUniApp/native/tang-detective/data/season.js b/TongjiUniApp/native/tang-detective/data/season.js new file mode 100644 index 0000000..d88339b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/data/season.js @@ -0,0 +1,5019 @@ +// Generated from the reviewed text snapshot. Do not hand-edit. +module.exports = { + "schemaVersion": "1.0.0-miniprogram", + "generatedAt": "2026-08-12T11:55:16.266Z", + "seasonMeta": { + "title": "唐侦探:桂香里的第十五桌", + "publisher": "甄养堂", + "subtitle": "一张桌,三代人,四十八年的吃饭习惯", + "format": "微信小程序横屏健康互动连环画", + "reviewLabel": "健康科普内容待甄养堂正式审签", + "purpose": "在真实中国饭桌场景里练习观察、判断和更稳妥的生活动作,不替代诊断或个体化治疗。", + "pingshuAudio": "/audio/table-15-pingshu-v3-directed-mobile.mp3", + "fullStoryAudio": "/audio/tang-detective-v4-full.mp3", + "contentVersion": "横屏互动连环画 · 第一季 V4.1 情感精修版" + }, + "interactionModel": { + "orientation": "手机横屏", + "entry": "封面进入章节目录,再进入横屏人物主场景;章节故事文字和玩法提示在同一章可读", + "primaryTarget": "人物是主要点击入口,物件是人物行为的手边证据", + "sceneLayout": "主场景约占60%,章节信息与进度侧栏约占40%;点击人物后判断浮窗仍保持人物情节约60%、选择区约40%", + "modal": "左侧人物、年代、身份、动作、台词与证据;右侧红叉绿勾判断;老人手机横屏优先大字和大按钮", + "repeatedActorRule": "每章固定4个事件,但个别章节只有3个可点击人物;同一人物可能承载2个连续事件,第一次完成后要明确提示再次点击", + "feedback": "答错隐藏原选项并给温和新线索,点击“再判断一次”后重选;答对给一句原因和一个可执行动作", + "chapterClosure": "完成4个正式事件后解锁1个无标准答案的情感互动;玩家选择后获得一条桌边回声", + "audioPolicy": "先做到纯文字完整可玩;音频后置,不阻塞文字、人物站位和互动验收" + }, + "counts": { + "chapters": 15, + "events": 60, + "emotions": 15, + "scenePeople": 80 + }, + "chapters": [ + { + "chapterId": "S01-C01", + "chapterNumber": 1, + "title": "开席前,少了一张桌", + "year": "2026", + "location": "桂香大饭店宴会前厅", + "sceneDescription": "2026年宴会前厅。十四桌与十六桌之间留出全画最大的空位;前景是纸请柬、手机和压痕,中景是小满、赵伯与乐乐,秦师傅只在后厨门影里。", + "sceneAlt": "2026年的桂香大饭店,中央留着一块少桌后的空地", + "narration": "老厂七十周年重聚宴就要开席。几张纸请柬明明写着“十五桌”,电子座位图却从十四直接跳到十六;地毯上,还留着四个刚压出来的桌脚印。", + "dialogue": "乐乐蹲下一比划:“它不是没来过,是刚走。”", + "act": "第一幕 · 一张桌为什么不见了", + "handnote": [ + "找座位要保留扫码、纸单和人工等多种方式。", + "发现矛盾先留证、核对和询问,不急着怪人。" + ], + "cliffhanger": "稿纸背面露出“请他们原谅……”;秦师傅听见“十五号铜牌”,门里“哐”地落下一只锅盖。", + "people": [ + { + "instanceId": "c01-xiaoman", + "characterId": "qin-xiaoman", + "name": "秦小满", + "title": "饭店经营者", + "eraLabel": "2026 · 33岁", + "stance": "站在签到台右侧,身体朝来宾开放", + "gaze": "看向刚进门的老工友", + "action": "把二维码座位图转向来宾,另一手压着未展开的纸名单", + "prop": "平板座位图、纸质名单", + "layer": 3, + "position": { + "xPercent": 12, + "yPercent": 20, + "widthPercent": 15, + "heightPercent": 56 + }, + "hotspotIds": [ + "S01-H01" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-xiaoman.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H01" + ] + }, + { + "instanceId": "c01-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2026 · 73岁", + "stance": "站在空位左前,微微眯眼", + "gaze": "在手机菜单与空桌位之间来回", + "action": "一手捏纸请柬,一手准备在手机上继续加菜", + "prop": "纸请柬、点餐手机", + "layer": 4, + "position": { + "xPercent": 29, + "yPercent": 30, + "widthPercent": 16, + "heightPercent": 55 + }, + "hotspotIds": [ + "S01-H02" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H02" + ] + }, + { + "instanceId": "c01-lele", + "characterId": "lele", + "name": "乐乐", + "title": "会直问的小孙子", + "eraLabel": "2026 · 10岁", + "stance": "蹲在地毯压痕旁", + "gaze": "沿椅脚拖痕看向侧门", + "action": "用手掌比量四个压痕,举起手机准备拍照", + "prop": "手机、卷尺小卡", + "layer": 5, + "position": { + "xPercent": 54, + "yPercent": 48, + "widthPercent": 15, + "heightPercent": 42 + }, + "hotspotIds": [ + "S01-H03" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lele.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H03" + ] + }, + { + "instanceId": "c01-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "2026 · 72岁", + "stance": "半身藏在后厨门框内", + "gaze": "越过门缝看空出来的十五桌位置", + "action": "把卷稿和铜牌压在围裙前,脚尖却向后退", + "prop": "发言稿、十五号铜牌", + "layer": 2, + "position": { + "xPercent": 79, + "yPercent": 17, + "widthPercent": 13, + "heightPercent": 52 + }, + "hotspotIds": [ + "S01-H04" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H04" + ] + } + ], + "events": [ + { + "hotspotId": "S01-H01", + "eventNumber": 1, + "actorId": "qin-xiaoman", + "actorName": "秦小满", + "actorInstanceId": "c01-xiaoman", + "label": "只有二维码的座位图", + "actionDescription": "小满站在电子座位图旁,只把二维码转向陆续进门的老人。", + "speech": "秦小满说:“大家先扫这里找座。”赵伯说:“纸请柬我看得懂,你把纸名单也铺开,我自己慢慢找。”", + "evidence": "屏幕字号很小;旁边虽有空桌,却没有铺开纸质名单,也没人主动问是否需要帮助。", + "question": "只让老人扫码找座位,不提供纸质名单或人工帮助,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "只有扫码入口,会让不熟悉手机或看不清小字的人失去自主找座的机会。", + "retryHint": "留意几位老人举着手机却没有操作,以及旁边尚未展开的纸质名单。", + "actionAdvice": "把大字纸质名单铺在同一入口,并安排工作人员主动询问是否需要帮助。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-xiaoman.jpg" + }, + { + "hotspotId": "S01-H02", + "eventNumber": 2, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c01-zhao", + "label": "已点十四道菜的手机", + "actionDescription": "赵伯看见手机里已有十四道菜,拇指仍停在“再来一道”的加号上。", + "speech": "赵伯说:“桌上不能空,空了显得心里没人。”乐乐数着手机回他:“已经十四道了,先看看人和份量吧。”", + "evidence": "来宾只有十人,他不是看见缺少某一品类,而是想用菜数把桌面“撑起来”。", + "question": "十个人已有十四道菜,还要再点一道“撑场面”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "情分不需要用超过人数和需要的菜量来证明,点得过多也会挤掉每个人真正想选的空间。", + "retryHint": "留意来宾人数、现有十四道菜的份量,以及赵伯只是想让桌面显得更满。", + "actionAdvice": "先核对人数、现有份量和菜品结构,确有不足再补。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H03", + "eventNumber": 3, + "actorId": "lele", + "actorName": "乐乐", + "actorInstanceId": "c01-lele", + "label": "地毯上的四个新压痕", + "actionDescription": "乐乐蹲在空位旁量四个新压痕,又抬头对照纸请柬和跳号座位图。", + "speech": "乐乐说:“它不是没摆过,是刚走。先拍脚印,再把请柬和座位图放一块儿看。”", + "evidence": "地毯压痕颜色更新,椅脚拖痕一直通向侧门,说明桌子早晨很可能真正摆过。", + "question": "先点压痕,再在浮窗中把请柬和跳号座位图放在一起核对,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "把现场痕迹与请柬、座位图共同核对,比只凭一个系统页面下结论更可靠。", + "retryHint": "对照电子图的跳号与地毯上四个颜色更新的桌脚压痕。", + "actionAdvice": "拍下压痕和拖痕,收好请柬,再调取调台记录核对。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lele.jpg" + }, + { + "hotspotId": "S01-H04", + "eventNumber": 4, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c01-qin", + "label": "秦师傅没能念出口的发言稿", + "actionDescription": "秦师傅躲在后厨门影里,把没念完的稿纸重新卷紧,十五号铜牌正夹在纸筒中。", + "speech": "秦师傅隔门说:“十五桌的人……齐了就告诉我。”乐乐说:“铜牌先放回稿纸旁,等秦爷爷出来把话说清。”", + "evidence": "稿首写着“给十五桌的老伙计们”,纸背露出“请他们原谅”,更像临场退缩而不是偷桌。", + "question": "发现铜牌卷在发言稿里,先询问秦师傅而不是认定有人偷桌,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "铜牌与发言稿更像秦师傅准备坦白后临时退缩的线索,不能直接变成对他人或服务员的指控。", + "retryHint": "留意铜牌与未念完的道歉稿原本卷在一起,尚无证据证明有人偷桌。", + "actionAdvice": "保持铜牌、纸筒和发言稿的原有关系,记录来源后再询问秦师傅。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + } + ], + "sceneAsset": "/package-game/assets/scenes/gui-xiang-2026.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM01", + "chapterId": "S01-C01", + "title": "把赵伯叫进来", + "triggerPosition": "四项证据齐全、众人前往旧物展柜之前", + "character": { + "id": "zhao-jianguo", + "name": "赵建国" + }, + "sceneText": "赵伯捏平手写请柬,站在十四桌与十六桌之间。四个新桌脚压痕就在脚边,他嘴上说“别管我”,脚却一直没有挪开。", + "prompt": "这一刻,你想怎样请赵伯一起查下去?", + "playerChoices": [ + { + "choiceId": "S01-EM01-A", + "text": "叫一声“赵伯”,把纸请柬递给他,请他带大家一起核对。", + "characterFeedback": "赵伯把请柬展开:“这还差不多。别替我安排,我跟你们一块儿查。”" + }, + { + "choiceId": "S01-EM01-B", + "text": "先搬来一把普通椅子,再问他愿不愿意坐着一起等结果。", + "characterFeedback": "赵伯把椅子拉近半步:“我站得住。不过你先问这一句,我心里就舒坦。”" + } + ], + "tableEcho": "空着的位置,不是少摆一张桌,是在等一个人被叫到名字。", + "echoCategory": "温暖" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C02", + "chapterNumber": 2, + "title": "铜牌背后的两张旧票", + "year": "2026 → 1978", + "location": "桂香旧物展柜", + "sceneDescription": "现代旧物展柜斜切画面。赵伯在左前认错碗,林秀兰居中纠正展签,乐乐在低处找铜牌细节,后厨门缝保留秦师傅的白衣背影。", + "sceneAlt": "桂香大饭店里的旧物展柜与被收起的十五号铜牌", + "narration": "铜牌背面有暗红漆、旧钉孔和两张破损饭菜票。赵伯一眼认出缺口碗,林秀兰却把照片转过来:“先别抢着认,缺口在另一边。”", + "dialogue": null, + "act": "第一幕 · 一张桌为什么不见了", + "handnote": [ + "记忆要和照片、痕迹、票证互相核对。", + "厂内饭菜票不是全国粮票,名字不能张冠李戴。" + ], + "cliffhanger": "林秀兰把挂钟拨到十一点半。宴会试音忽然变成四十八年前的厂广播。", + "people": [ + { + "instanceId": "c02-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2026 · 73岁", + "stance": "俯身贴近展柜玻璃", + "gaze": "盯住右缺口蓝边搪瓷碗", + "action": "指着碗认领,另一手仍捏着自己的老照片", + "prop": "旧照片、搪瓷碗", + "layer": 4, + "position": { + "xPercent": 7, + "yPercent": 25, + "widthPercent": 16, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H06" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H06" + ] + }, + { + "instanceId": "c02-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "2026 · 69岁", + "stance": "站在展柜中央侧前", + "gaze": "先看展签,再看赵伯手中的照片", + "action": "用票夹压住错误展签,把铜牌翻面拍照量尺寸", + "prop": "饭票夹、手机、软尺", + "layer": 5, + "position": { + "xPercent": 35, + "yPercent": 18, + "widthPercent": 16, + "heightPercent": 61 + }, + "hotspotIds": [ + "S01-H05", + "S01-H08" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H05", + "S01-H08" + ] + }, + { + "instanceId": "c02-lele", + "characterId": "lele", + "name": "乐乐", + "title": "会直问的小孙子", + "eraLabel": "2026 · 10岁", + "stance": "蹲在展柜右下角", + "gaze": "从铜牌铁丝抬到合影最边缘", + "action": "指认破损票、棕布包与桌沿旧漆,写下“待核对”", + "prop": "线索卡、铅笔", + "layer": 6, + "position": { + "xPercent": 61, + "yPercent": 47, + "widthPercent": 14, + "heightPercent": 41 + }, + "hotspotIds": [ + "S01-H07" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lele.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H07" + ] + }, + { + "instanceId": "c02-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "2026 · 72岁", + "stance": "只露背影在后厨门缝", + "gaze": "侧耳听展柜前的谈话", + "action": "听到铜牌后失手碰落锅盖", + "prop": "白围裙、锅盖", + "layer": 1, + "position": { + "xPercent": 82, + "yPercent": 15, + "widthPercent": 11, + "heightPercent": 48 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "indigo", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H05", + "eventNumber": 5, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c02-lin", + "label": "写成“粮票”的展签", + "actionDescription": "林秀兰用旧票夹压住写着“全国粮票”的展签,示意先看票面单位。", + "speech": "林秀兰说:“先看票面。这是桂香厂饭菜票,不是全国粮票。名字写错了,后头那个人的日子也会跟着写错。”", + "evidence": "票面印的是桂香机械厂内部结算信息,使用范围与全国粮票不同。", + "question": "把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "厂内饭菜票与全国粮票的使用范围和用途不同,名称准确才能让年代和人的经历不被混淆。", + "retryHint": "查看票面单位、使用地点和结算用途,别只看它们都叫“票”。", + "actionAdvice": "查看票面单位和适用范围,改正展签并保留原始来源说明。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + }, + { + "hotspotId": "S01-H06", + "eventNumber": 6, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c02-zhao", + "label": "缺口搪瓷碗", + "actionDescription": "赵伯隔着玻璃认领缺口碗,手已经指向自己,林秀兰却把旧照片翻了个面。", + "speech": "赵伯说:“这缺口我认得。”林秀兰说:“照片转过来再看,你那只缺口在左,这只在右。慢慢看,没人拿一次记错笑你。”", + "evidence": "展柜碗缺口在右,赵伯旧照里的白底绿边碗缺口在左,两只碗不能只凭一句回忆合并。", + "question": "只凭赵伯一句“这是我的”就确认物主,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "尊重老人记忆不等于停止核对;多项证据互证,既能认准物主,也避免因一次记错否定本人。", + "retryHint": "对照缺口左右、搪瓷碗颜色与旧照片中的持碗人。", + "actionAdvice": "转正照片核对左右、颜色和旧账,再把物主与借展状态写清。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H07", + "eventNumber": 7, + "actorId": "lele", + "actorName": "乐乐", + "actorInstanceId": "c02-lele", + "label": "合影边缘的布包与桌沿铁包边", + "actionDescription": "乐乐沿旧合影边缘找到半张年轻面孔、棕布包和桌沿铁包边。", + "speech": "乐乐说:“布包像唐爷爷的,桌沿漆也像铜牌背后。我先记‘待核对’,不抢着写‘就是’。”", + "evidence": "布包能帮助辨认年轻小唐,暗红旧漆能帮助追踪桌子,但两者都还需要别的线索互证。", + "question": "用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "跨年代重复物件有助于认人和追踪桌子,但相似不等于已经证明。", + "retryHint": "相同布包和漆色只能提供核对方向,还需要其他照片与实物印证。", + "actionAdvice": "把布包、人物位置和暗红铁包边加入线索卡,等待其他照片与实物互证。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lele.jpg" + }, + { + "hotspotId": "S01-H08", + "eventNumber": 8, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c02-lin", + "label": "铜牌背面红漆和钉孔", + "actionDescription": "林秀兰把铜牌翻面拍下红漆和钉孔,再放大铁丝上两枚边孔磨裂的饭菜票。", + "speech": "林秀兰说:“红漆拍下来,孔距也量上。证据会说话,可别逼它一次把所有话说完。”", + "evidence": "红漆、孔位与破损票可留作后续比对,但单独一张近照不能证明四十八年的全部经历。", + "question": "拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "漆痕、孔位和破票可供后续比对,但单件物证不足以还原完整历史。", + "retryHint": "一张铜牌的红漆与钉孔只能证明部分痕迹,不能独自讲完四十八年。", + "actionAdvice": "保留铜牌原状,拍摄尺寸与近照,记录两枚破票的连接关系,等待旧桌板出现。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境", + "饮食行为", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + } + ], + "sceneAsset": "/package-chapter-02/assets/scenes/gui-xiang-1978.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM02", + "chapterId": "S01-C02", + "title": "慢一点认", + "triggerPosition": "旧物核对结束、挂钟被拨回十一点半之前", + "character": { + "id": "lin-xiulan", + "name": "林秀兰" + }, + "sceneText": "赵伯把缺口碗认错了,笑声刚起,林秀兰没有纠着他说,而是把旧照片轻轻转回正面。", + "prompt": "面对一段记得不太清楚的往事,你想怎样陪他们继续认?", + "playerChoices": [ + { + "choiceId": "S01-EM02-A", + "text": "请赵伯按着照片慢慢讲,先说他还认得的人。", + "characterFeedback": "林秀兰把照片推近:“记错一只碗不算什么,人还认得,就接着往下说。”" + }, + { + "choiceId": "S01-EM02-B", + "text": "把饭票、缺口碗和旧照片摆在一起,请两位老人共同核对。", + "characterFeedback": "林秀兰点点票角:“让物件帮着想,不拿物件压人。咱们一件一件对。”" + } + ], + "tableEcho": "旧东西会认错,慢一点核对,人就不会被轻易抹掉。", + "echoCategory": "生活" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C03", + "chapterNumber": 3, + "title": "厂铃一响,饭盆就响", + "year": "1978", + "location": "桂香机械厂集体食堂", + "sceneDescription": "1978年厂铃刚停的集体食堂。工人流向窗口,长桌横贯中景;赵建国端大碗居中,秦师傅守窗口,林秀兰分票夹,小唐在水池边,老吕与长凳工友承担两个前景动作。", + "sceneAlt": "1978年桂香机械厂食堂,长桌长凳与取饭窗口清晰可见", + "narration": "2026年,乐乐指着旧照片问:“爷爷,这一桌怎么都是大碗主食?”赵伯把照片转正:“别拿一张照片说一辈子。先看那天吃什么、下午干什么。”画页翻回1978年。十一点半厂铃一响,搪瓷碗、饭菜票和长凳一齐热闹起来;年轻赵建国干的是重活,端起大碗。小唐卫生员站在水池边,默默看着大家的手和凳子。", + "dialogue": null, + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "理解重体力劳动年代的供应与饭量背景,不嘲笑过去。", + "进食前清洁双手,长凳起身先提醒同伴。" + ], + "cliffhanger": "赵建国把第三碗放上桌:“今天让你看看,劳动骨干怎么吃!”", + "people": [ + { + "instanceId": "c03-old-lv", + "characterId": "old-lv", + "name": "老吕", + "title": "晚班钳工", + "eraLabel": "1978 · 晚班钳工", + "stance": "从车间快步跨进食堂", + "gaze": "看着手中馒头", + "action": "沾机油的手正要抓食物,蓝边缺口碗夹在臂弯", + "prop": "馒头、右缺口搪瓷碗", + "layer": 5, + "position": { + "xPercent": 4, + "yPercent": 24, + "widthPercent": 14, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H09" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H09" + ] + }, + { + "instanceId": "c03-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "1978 · 25岁", + "stance": "挺胸站在长桌中央", + "gaze": "看向一盆白菜土豆和主食", + "action": "托起大碗,让普通菜色和下午的重体力劳动同时进入画面", + "prop": "左缺口白底绿边碗", + "layer": 4, + "position": { + "xPercent": 24, + "yPercent": 20, + "widthPercent": 16, + "heightPercent": 61 + }, + "hotspotIds": [ + "S01-H11" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H11" + ] + }, + { + "instanceId": "c03-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "1978 · 21岁", + "stance": "站在窗口外侧票台", + "gaze": "在饭菜票和晚班名单之间核对", + "action": "左右手使用不同票夹,颜色和用途清楚分开", + "prop": "饭菜票夹、晚班人数单", + "layer": 3, + "position": { + "xPercent": 44, + "yPercent": 17, + "widthPercent": 14, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H12" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H12" + ] + }, + { + "instanceId": "c03-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "1978 · 21岁", + "stance": "站在右侧水池旁,身体微侧", + "gaze": "看向老吕的手和起身长凳", + "action": "擦干双手后才碰记录簿,准备提醒而不抢话", + "prop": "旧棕布卫生包、软皮记录簿", + "layer": 4, + "position": { + "xPercent": 61, + "yPercent": 28, + "widthPercent": 13, + "heightPercent": 52 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "indigo", + "eventIds": [] + }, + { + "instanceId": "c03-worker", + "characterId": "old-worker", + "name": "同凳工友", + "title": "桂香厂旧同事", + "eraLabel": "1978 · 食堂工友", + "stance": "一人将起未起,另一人急忙压住长凳", + "gaze": "彼此看向失衡的凳面", + "action": "起身动作悬在半途,汤碗正向一侧滑", + "prop": "长凳、汤碗", + "layer": 6, + "position": { + "xPercent": 76, + "yPercent": 45, + "widthPercent": 18, + "heightPercent": 40 + }, + "hotspotIds": [ + "S01-H10" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H10" + ] + }, + { + "instanceId": "c03-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "1978 · 24岁", + "stance": "隔着取饭窗口站直", + "gaze": "沿队伍喊下一位", + "action": "握长柄饭勺分饭,只作为年代关系背景", + "prop": "长柄饭勺", + "layer": 1, + "position": { + "xPercent": 85, + "yPercent": 8, + "widthPercent": 10, + "heightPercent": 39 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "jade", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H09", + "eventNumber": 9, + "actorId": "old-lv", + "actorName": "老吕", + "actorInstanceId": "c03-old-lv", + "label": "沾机油的手与馒头", + "actionDescription": "老吕刚从车间赶来,沾机油的手已经伸向馒头。", + "speech": "小唐说:“老吕,先把手清干净再拿吃的,我替你把碗放稳。”老吕说:“差点把车间也吃进去了,我先去洗。”", + "evidence": "工作污物仍清楚留在指缝和掌侧,水池与擦手布就在几步之外。", + "question": "没有清洁双手就直接抓食物,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "工作污物不应直接带到入口食物上,忙和赶时间也不能替代基本清洁。", + "retryHint": "留意馒头会直接入口,而老吕指缝和掌侧仍有机油与工作污物。", + "actionAdvice": "进食前按现场条件把双手清洁并擦干,再接触食物。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H10", + "eventNumber": 10, + "actorId": "old-worker", + "actorName": "老工友", + "actorInstanceId": "c03-worker", + "label": "两人同坐的长凳", + "actionDescription": "同坐长凳的工友突然起身,另一端的人和饭碗同时向后翘起。", + "speech": "起身的工友说:“坐稳了——我先起!”同凳工友说:“这声早半拍,我的汤就保住了。”", + "evidence": "长凳由两人共同压住重心,一人不提醒就离座,可能让同伴失衡摔倒。", + "question": "一个人不提醒同伴就突然起身,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "共坐长凳时突然起身可能使同伴失去平衡,碗里的热食也可能泼出。", + "retryHint": "观察两人共同压住的长凳,在一端突然失去重量时如何翘起。", + "actionAdvice": "起身前先说一声“坐稳了”,确认同伴坐稳后再离座。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H11", + "eventNumber": 11, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c03-zhao", + "label": "白菜、土豆和大盆主食", + "actionDescription": "1978年的画页里,年轻赵建国端着大盆主食;盆边是白菜、土豆,窗外工人正准备下午的重活。", + "speech": "乐乐在2026年问:“爷爷,这一桌怎么都是大碗主食?”赵伯回答:“别拿一张照片说一辈子。那天窗口就这几样,下午还得抬机座。”", + "evidence": "1978年的菜色、供应和重体力劳动都进入同一画面,不能脱离当时条件评价一代人。", + "question": "用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "这张照片里的饭量与当天供应、劳动强度有关;脱离具体日子嘲笑一代人的吃法不准确,也无助于今天调整。", + "retryHint": "别让一张照片替整个年代作证;再看当天的小黑板、车间机座和下午排班。", + "actionAdvice": "先理解年代条件,再结合今天的活动、生活和个人方案讨论份量。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H12", + "eventNumber": 12, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c03-lin", + "label": "饭菜票与晚班人数单", + "actionDescription": "林秀兰左手收饭菜票,右手把抢修和晚班人数单夹进另一只票夹。", + "speech": "林秀兰说:“饭菜票放左边,晚班人数单夹右边。票是票,人是人,回来吃饭的人不能漏。”", + "evidence": "一份用于结算,一份用于留饭;若混为一叠,晚班人数很容易在忙乱中被漏掉。", + "question": "把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "两种凭据用途不同,分开核对才能避免忙乱中漏掉晚班和抢修人员的用餐安排。", + "retryHint": "一份凭据管结算,一份管谁会晚回来;混成一叠容易漏掉晚班人员。", + "actionAdvice": "使用不同票夹或明显标记分开管理,并在关窗前再次核对晚班人数。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境", + "饮食行为", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + } + ], + "sceneAsset": "/package-chapter-03/assets/scenes/gui-xiang-1978.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM03", + "chapterId": "S01-C03", + "title": "问一句累不累", + "triggerPosition": "晚班人数单被看见、第三碗饭的起哄开始之前", + "character": { + "id": "tang-shouan", + "name": "唐守安" + }, + "sceneText": "年轻的小唐从医务室一路跑来,替人洗净水杯、挪好长凳,又退回桌角。满屋都在问谁还能多干一点,没人问他跑这一趟累不累。", + "prompt": "你想怎样给这个不起眼的小唐留一点位置?", + "playerChoices": [ + { + "choiceId": "S01-EM03-A", + "text": "问他:“从医务室跑回来,累不累?先喘口气吧。”", + "characterFeedback": "小唐愣了一下,才笑:“还成。你这一问,我倒想起来该喘口气了。”" + }, + { + "choiceId": "S01-EM03-B", + "text": "往长凳里挪一挪,给他的水杯和饭碗空出位置。", + "characterFeedback": "小唐把水杯放下,小声说:“原来桌边还给我留着地方。”" + } + ], + "tableEcho": "看见一个人,不只看他能干多少,也问他累不累。", + "echoCategory": "成长" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C04", + "chapterNumber": 4, + "title": "劳动骨干的第三碗饭", + "year": "1978", + "location": "桂香机械厂集体食堂", + "sceneDescription": "第三碗饭悬在饭勺和桌面之间。赵建国的嘴硬、扶桌和松皮带落在同一人物上;小唐占右下桌角推水杯,林秀兰从侧后递小碗和空凳,起哄工友围而不堵。", + "sceneAlt": "1978年食堂饭桌旁,工友正为第三碗饭起哄", + "narration": "“人是铁,饭是钢,一顿不吃饿得慌!”工友们起哄比饭量。赵建国嘴上说还能吃,手却已经扶住桌沿;小唐把水杯推过去,只说:“别又急又撑,待会儿弯腰抬东西更难受。”", + "dialogue": "赵建国摆手:“你懂个屁,我可是劳动骨干!”", + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "饭量不能证明劳动能力,真实饥饱不必为面子让路。", + "提醒进食过急过撑,不等于禁止某一种主食。" + ], + "cliffhanger": "下午临时抢修,赵建国带队留下;关窗前,秦师傅却把最后一锅饭全分完了。", + "people": [ + { + "instanceId": "c04-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "1978 · 25岁", + "stance": "半站半倚在长桌前", + "gaze": "先看举起的饭勺,再避开小唐目光", + "action": "带头比饭量;同一人物另一手松皮带、扶桌,仍准备接第三碗", + "prop": "大碗、饭勺、旧皮带", + "layer": 6, + "position": { + "xPercent": 18, + "yPercent": 15, + "widthPercent": 20, + "heightPercent": 69 + }, + "hotspotIds": [ + "S01-H13", + "S01-H14" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H13", + "S01-H14" + ] + }, + { + "instanceId": "c04-workers", + "characterId": "old-worker", + "name": "起哄工友", + "title": "桂香厂旧同事", + "eraLabel": "1978 · 车间工友", + "stance": "围桌探身,留出赵建国的扶桌手", + "gaze": "看饭勺,不看他已经不舒服的动作", + "action": "拍桌喊“第三碗才算数”,笑声把真实饥饱盖住", + "prop": "饭勺、搪瓷缸", + "layer": 4, + "position": { + "xPercent": 3, + "yPercent": 27, + "widthPercent": 15, + "heightPercent": 51 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "", + "markerTone": "jade", + "eventIds": [] + }, + { + "instanceId": "c04-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "1978 · 21岁", + "stance": "坐在右下桌角,不挡人群", + "gaze": "看赵建国扶桌的手而不是饭碗", + "action": "把水杯推过去,只提醒慢一点、别过撑,不禁某一种主食", + "prop": "水杯、旧布包", + "layer": 7, + "position": { + "xPercent": 52, + "yPercent": 43, + "widthPercent": 14, + "heightPercent": 44 + }, + "hotspotIds": [ + "S01-H15" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H15" + ] + }, + { + "instanceId": "c04-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "1978 · 21岁", + "stance": "站在桌侧后,身体为人让开", + "gaze": "看赵建国的脸色", + "action": "放下小碗,拉开一张空凳,给他不丢面子的停顿", + "prop": "小碗、空凳", + "layer": 5, + "position": { + "xPercent": 70, + "yPercent": 22, + "widthPercent": 15, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H16" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H16" + ] + }, + { + "instanceId": "c04-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "1978 · 24岁", + "stance": "在窗口后侧身收锅", + "gaze": "看向即将见底的最后一锅饭", + "action": "把饭分完,为晚班无饭的下一章留下因果", + "prop": "饭锅、木锅盖", + "layer": 1, + "position": { + "xPercent": 86, + "yPercent": 9, + "widthPercent": 9, + "heightPercent": 39 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H13", + "eventNumber": 13, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c04-zhao", + "label": "起哄工友和高举的饭勺", + "actionDescription": "赵建国把饭勺举得像奖杯,带着工友起哄比谁先吃下第三碗。", + "speech": "工友喊:“人是铁,饭是钢,一顿不吃饿得慌!劳动骨干,第三碗才算数!”赵建国把碗一举:“添上!”", + "evidence": "这不是按真实饥饿添饭,而是在用饭量给劳动能力排名。", + "question": "为证明能干,组织“谁吃得多”的比赛,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "饭量不能证明劳动能力;把进食变成排名,会让人为了面子忽略自己的饥饱感受。", + "retryHint": "留意赵建国接第三碗时先看工友的反应,而不是自己的饥饱感受。", + "actionAdvice": "不组织也不参加饭量比赛,不因起哄突然多吃或故意漏餐。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H14", + "eventNumber": 14, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c04-zhao", + "label": "赵伯松开的皮带和扶桌手", + "actionDescription": "赵建国嘴上说不撑,一只手却松皮带、扶桌沿,另一只手还要去接饭勺。", + "speech": "赵建国说:“这算什么,我还能抬。”小唐说:“先坐会儿,把不舒服说出来,也不耽误你是骨干。”", + "evidence": "动作已经暴露明显不舒服,他却准备立刻弯腰参加下午的抬运。", + "question": "已经很撑、仍隐瞒不适并准备马上弯腰抬重物,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "吃得过急过撑可能带来不适;隐瞒感受会让同伴无法及时调整工作或提供帮助。", + "retryHint": "别只听“我没事”,还要看松开的皮带、扶桌的手和接下来要弯腰抬重物。", + "actionAdvice": "停止继续添饭,先坐下休息并说出不适;必要时停止工作并求助。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H15", + "eventNumber": 15, + "actorId": "tang-shouan", + "actorName": "唐守安", + "actorInstanceId": "c04-tang", + "label": "小唐的提醒", + "actionDescription": "小唐只劝“别又急又撑”,工友却故意曲解成他不许大家吃主食。", + "speech": "小唐说:“我不查你几碗,也没说主食不能吃。我只提醒你别吃得又急又撑。”", + "evidence": "他的提醒针对速度、过撑和隐瞒不适,没有给米饭或馒头贴永久红叉。", + "question": "把“别又急又撑”理解成“主食一口都不能吃”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "提醒进食速度和身体感受,不等于禁止某一种主食,更不能把健康建议说成“什么都不能吃”。", + "retryHint": "小唐说的是进食速度、撑和硬扛,并没有禁止某一种主食。", + "actionAdvice": "听清提醒针对的具体行为,再结合本人需要决定是否继续添饭。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-shouan.jpg" + }, + { + "hotspotId": "S01-H16", + "eventNumber": 16, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c04-lin", + "label": "林秀兰放下的小碗和空凳", + "actionDescription": "林秀兰放下一只小碗,推开空凳,先给赵建国一个能停下来的位置。", + "speech": "林秀兰说:“小碗放这儿,空凳也给你拉开。先坐,真不舒服就说,没人因为你停一停,就把骨干牌子摘了。”", + "evidence": "她没有当众揭短,只问是否需要坐一会儿,并把水和通道都留出来。", + "question": "看见同伴扶桌后,先问是否需要坐一会儿,而不是继续起哄,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "给人一个不丢面子的停顿,比继续起哄或当众训斥更容易让他表达真实感受。", + "retryHint": "留意空凳、水和不伤面子的话,给了赵建国一个可以停下来的台阶。", + "actionAdvice": "留出座位和水,先问是否需要休息;出现明显不适时停止相关活动并求助。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + } + ], + "sceneAsset": "/package-chapter-04/assets/scenes/gui-xiang-1978.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM04", + "chapterId": "S01-C04", + "title": "给嘴硬的人一张凳", + "triggerPosition": "第三碗被放下、下午抢修通知贴出之前", + "character": { + "id": "zhao-jianguo", + "name": "赵建国" + }, + "sceneText": "赵建国嘴上还在说“劳动骨干扛得住”,一只手却已经扶住桌沿。周围的笑声没有恶意,却让他更不好意思停下来。", + "prompt": "不拆穿他的逞强,你想怎样接住这一刻?", + "playerChoices": [ + { + "choiceId": "S01-EM04-A", + "text": "把空凳拉近,只说:“先坐稳,下午的活儿还等你拿主意。”", + "characterFeedback": "赵建国嘴上嘟囔“我又没老”,人却坐下了:“那我就坐这一会儿。”" + }, + { + "choiceId": "S01-EM04-B", + "text": "把水杯推过去,低声问:“是真饿,还是大家一喊,你不好意思停?”", + "characterFeedback": "赵建国看了看四周:“都看着呢。你小点声……水给我。”" + } + ], + "tableEcho": "饭能添,面子也要留;劝人停一停,先给他一张凳。", + "echoCategory": "生活" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C05", + "chapterNumber": 5, + "title": "第十五桌给谁留", + "year": "1978", + "location": "熄灯后的桂香食堂", + "sceneDescription": "熄灯后的食堂重新点火。灶火和汤汽在后景,门口是晚班工友;前景赵建国掰馒头,小唐拖凳,林秀兰压名单,秦师傅从灶台走向最后一张长桌。", + "sceneAlt": "半暗的1978年食堂重新点火,蒸汽围住一张留给晚班的长桌", + "narration": "晚班回来,窗口已经熄灯。秦师傅重新点火,林秀兰把值班人数条和饭菜票分开压好;赵建国把半个馒头掰给同伴,小唐拖来长凳。白菜热汤面冒起了白汽。", + "dialogue": null, + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "错过饭点不靠空腹硬扛,要说明情况并解决基本需要。", + "留饭、留座和人数登记,能让来迟的人也被看见。" + ], + "cliffhanger": "众人在新留出的桌旁挤成一张合影,桌沿第一次挂上“15”号铜牌。", + "people": [ + { + "instanceId": "c05-old-lv", + "characterId": "old-lv", + "name": "老吕", + "title": "晚班钳工", + "eraLabel": "1978 · 晚班钳工", + "stance": "疲惫站在已熄灯窗口前", + "gaze": "看空锅后转向车间门", + "action": "准备空腹回去继续当班,又被小唐叫住", + "prop": "右缺口搪瓷碗", + "layer": 5, + "position": { + "xPercent": 4, + "yPercent": 23, + "widthPercent": 14, + "heightPercent": 60 + }, + "hotspotIds": [ + "S01-H17" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H17" + ] + }, + { + "instanceId": "c05-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "1978 · 25岁", + "stance": "坐在长桌左端,肩膀放松下来", + "gaze": "看门口几位晚班同伴", + "action": "把仅剩半个馒头掰开递出去,不按“谁最能干”分", + "prop": "半个馒头", + "layer": 6, + "position": { + "xPercent": 23, + "yPercent": 38, + "widthPercent": 15, + "heightPercent": 47 + }, + "hotspotIds": [ + "S01-H18" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H18" + ] + }, + { + "instanceId": "c05-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "1978 · 21岁", + "stance": "站在窗台与长桌之间", + "gaze": "直看秦师傅,把名单举到灯下", + "action": "分开放好晚班人数单和两枚破损饭菜票", + "prop": "人数单、饭菜票夹", + "layer": 5, + "position": { + "xPercent": 42, + "yPercent": 22, + "widthPercent": 14, + "heightPercent": 58 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "ochre", + "eventIds": [] + }, + { + "instanceId": "c05-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "1978 · 24岁", + "stance": "从重新燃起的灶台转向最后一张长桌", + "gaze": "先看锅中热面,再看门口晚班人数", + "action": "用明确保存的原料现做热汤面,并敲紧十五号铜牌", + "prop": "汤锅、长柄勺、十五号铜牌", + "layer": 7, + "position": { + "xPercent": 60, + "yPercent": 14, + "widthPercent": 16, + "heightPercent": 66 + }, + "hotspotIds": [ + "S01-H19", + "S01-H20" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H19", + "S01-H20" + ] + }, + { + "instanceId": "c05-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "1978 · 21岁", + "stance": "弯身拖来长凳", + "gaze": "看老吕落座的位置", + "action": "摆稳凳脚、递水,不替任何人总结", + "prop": "长凳、水杯", + "layer": 6, + "position": { + "xPercent": 79, + "yPercent": 44, + "widthPercent": 15, + "heightPercent": 40 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H17", + "eventNumber": 17, + "actorId": "old-lv", + "actorName": "老吕", + "actorInstanceId": "c05-old-lv", + "label": "熄灯后的空窗口", + "actionDescription": "晚班工友面对熄灯空窗口,准备什么也不说就继续去干下一段活。", + "speech": "老吕说:“算了,空一顿也能顶。”小唐拦住门:“先把情况说清,硬撑不是安排。”", + "evidence": "饭点已错过、当班还没结束,是否能安全继续不能只靠一句“扛得住”。", + "question": "因为错过饭点就继续空腹硬扛晚班,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "是否能安全继续工作不能只凭意志硬撑;错过饭点后需要结合个人情况及时说明并解决基本需要。", + "retryHint": "“我能顶”只是习惯不麻烦别人,眼前并没有可行的进食与休息安排。", + "actionAdvice": "尽快向现场负责人说明情况,按单位安排和本人需要解决进食与休息。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H18", + "eventNumber": 18, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c05-zhao", + "label": "仅剩的半个馒头", + "actionDescription": "白天饭量最大的赵建国掰开仅剩馒头,却有人提议全给“最能干的”那一位。", + "speech": "老工友说:“就这一个,先给最能干的。”赵建国把馒头掰开:“都忙到这个点了,一人先垫半个。锅马上起。”", + "evidence": "门口回来的是一组晚班工友,按贡献分唯一食物会让其他人的需要消失。", + "question": "把所有食物都留给“最能干的人”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "基本需要不能只按贡献大小分配;每个人的在场和需要都应先被确认。", + "retryHint": "只按贡献大小分配,会把安静的人、陪护的人和来迟的人再次漏掉。", + "actionAdvice": "先确认人数和个人需要,再共同商量临时分配并尽快安排足够食物。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H19", + "eventNumber": 19, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c05-qin", + "label": "重新点火的锅", + "actionDescription": "秦师傅重新点火,用保存条件明确的白菜和面现做热汤面。", + "speech": "秦师傅说:“午间久放的菜不赌,重新做。明天名单报几个人,就按几个人另留原料。”", + "evidence": "他没有翻出午间熟食再热;从第二天起才按报餐人数妥善另留原料。", + "question": "使用保存条件明确的原料,重新现做白菜热汤面,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "保存条件不明或久置的食物不能仅靠再次加热保证安全,现做和完善流程更稳妥。", + "retryHint": "保存条件不明、已经久置的熟食,不会因为“再热一遍”就自动安全。", + "actionAdvice": "不使用保存情况不明的久置食物;以后按报餐人数妥善另留原料,到人后现做。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + }, + { + "hotspotId": "S01-H20", + "eventNumber": 20, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c05-qin", + "label": "第十五桌的空位", + "actionDescription": "秦师傅把最后一张长桌拖近后门,敲紧十五号铜牌,让迟到的人真正坐下。", + "speech": "秦师傅说:“长桌拖灯底下,凳子摆开。十五桌往后别撤,给晚班留着。”", + "evidence": "长凳、热饭、人数单与留座同时出现,支持不是把来迟的人隔到角落站着吃。", + "question": "让晚班、陪护和来迟的人也能坐下吃一口热饭,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "真正的支持不是按疾病、能力或贡献分桌,而是让晚班、陪护和来迟的人也有热饭和座位。", + "retryHint": "这张桌没有设置身份门槛,它让原本没有位置的晚班、陪护和来迟者重新坐下。", + "actionAdvice": "保留清楚的报餐、留饭和留座流程,让来迟的人能坐下并拥有选择。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + } + ], + "sceneAsset": "/package-chapter-05/assets/scenes/gui-xiang-1978.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM05", + "chapterId": "S01-C05", + "title": "为晚归的人留灯", + "triggerPosition": "第十五号铜牌挂好、第一次合影之前", + "character": { + "id": "qin-zhicheng", + "name": "秦志成" + }, + "sceneText": "灶火重新亮起来,热汤的蒸汽慢慢把门口站着的人连成一桌。秦师傅拿着十五号铜牌,还在想怎样才能不再漏掉晚归的人。", + "prompt": "你想和秦师傅一起,为这张桌留下些什么?", + "playerChoices": [ + { + "choiceId": "S01-EM05-A", + "text": "挂好十五号铜牌,再写一张“晚班、陪护到齐再收”的人数单。", + "characterFeedback": "秦师傅把纸压在票夹下:“牌子得挂,人数也得记。人不能再少算。”" + }, + { + "choiceId": "S01-EM05-B", + "text": "先拉开长凳,挨个问晚归的人想吃面、馒头,还是先喝口热汤。", + "characterFeedback": "秦师傅重新揭开锅盖:“先问一声,热饭才算真正给到了人。”" + } + ], + "tableEcho": "有人为晚归的人留了一盏灯。", + "echoCategory": "温暖" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C06", + "chapterNumber": 6, + "title": "木牌翻面的那一天", + "year": "1995", + "location": "从厂食堂改成的桂香饭馆", + "sceneDescription": "1995年木牌正翻面。秦师傅站在门槛中央,一半是职工食堂、一半是桂香饭馆;左右两只手分别递饭票和现金,林秀兰护员工饭,第十五桌完整留在深处。", + "sceneAlt": "1995年桂香饭馆开业,旧食堂格局与新招牌同时存在", + "narration": "木牌从“职工食堂”翻成“桂香饭馆”。一只手还递着旧饭票,另一只手已经拿出现金。秦师傅站在门槛中间:经营办法变了,老工友和员工还能不能坐下吃饭?", + "dialogue": null, + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "经营权不等于房屋所有权,年代证据要看合同和清单。", + "制度改变时先解释、给帮助,不拿不熟悉的人取笑。" + ], + "cliffhanger": "第一桌社会客人进门,指着靠门的第十五桌:“这张给我们拼个大桌。”", + "people": [ + { + "instanceId": "c06-old-worker", + "characterId": "old-worker", + "name": "老工友", + "title": "桂香厂旧同事", + "eraLabel": "1995 · 旧厂职工", + "stance": "站在门槛左侧,手停在半空", + "gaze": "困惑地看自己递出的老饭票", + "action": "按旧习惯付款,听见笑声后想把手缩回去", + "prop": "老饭票、搪瓷缸", + "layer": 4, + "position": { + "xPercent": 4, + "yPercent": 25, + "widthPercent": 14, + "heightPercent": 57 + }, + "hotspotIds": [ + "S01-H22" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H22" + ] + }, + { + "instanceId": "c06-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "1995 · 41岁", + "stance": "跨在门槛中央,两脚分处新旧招牌下", + "gaze": "在合同、饭票、现金和第十五桌之间来回", + "action": "按住承包文件澄清资产边界,也挡住客人指向员工桌的手", + "prop": "承包责任书、资产清单、现金铁盒", + "layer": 6, + "position": { + "xPercent": 28, + "yPercent": 12, + "widthPercent": 19, + "heightPercent": 72 + }, + "hotspotIds": [ + "S01-H21", + "S01-H24" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H21", + "S01-H24" + ] + }, + { + "instanceId": "c06-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "1995 · 38岁", + "stance": "站在后厨口与收银台之间", + "gaze": "看员工轮班纸,再看门外新客", + "action": "给两碗员工饭扣盖,排好错峰时间", + "prop": "员工饭、碗盖、排班纸", + "layer": 5, + "position": { + "xPercent": 54, + "yPercent": 22, + "widthPercent": 15, + "heightPercent": 59 + }, + "hotspotIds": [ + "S01-H23" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H23" + ] + }, + { + "instanceId": "c06-guest", + "characterId": "service-worker", + "name": "第一位社会客人", + "title": "当班服务人员", + "eraLabel": "1995 · 饭馆新客", + "stance": "站在门外右侧,探身看大厅", + "gaze": "指向靠门第十五桌", + "action": "递现金并询问能否拼大桌,等待店家安排", + "prop": "现金、提包", + "layer": 4, + "position": { + "xPercent": 74, + "yPercent": 26, + "widthPercent": 14, + "heightPercent": 55 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "", + "markerTone": "indigo", + "eventIds": [] + }, + { + "instanceId": "c06-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "1995 · 38岁", + "stance": "从告示栏旁经过,只露侧身", + "gaze": "低头看夹在自行车筐里的学习资料", + "action": "在经营冲突外保持边缘,交代合规学习路径", + "prop": "课程讲义、旧布包", + "layer": 1, + "position": { + "xPercent": 87, + "yPercent": 17, + "widthPercent": 9, + "heightPercent": 42 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H21", + "eventNumber": 21, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c06-qin", + "label": "承包经营责任书", + "actionDescription": "秦师傅刚签承包经营责任书,手便下意识按住桌凳清单,像是在说“都是我的”。", + "speech": "秦师傅的手刚按上承包责任书,林秀兰便敲了敲旁边的资产清单:“你承的是经营,别把老厂房也揣进围裙兜里。”", + "evidence": "合同、期限和资产清单仍分开放置,经营权并不自动等于房屋与旧物所有权。", + "question": "说秦师傅签字后,厂房和食堂就全归个人所有,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "经营权、使用安排和资产所有权不是同一件事,需要分别核对合同期限与资产清单。", + "retryHint": "签下经营合同,是否就等于厂房、桌凳和旧物都变成了个人财产?", + "actionAdvice": "把承包文件、期限和资产清单放在一起核对;无法确认的旧物先登记,不擅自处置。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + }, + { + "hotspotId": "S01-H22", + "eventNumber": 22, + "actorId": "old-worker", + "actorName": "老工友", + "actorInstanceId": "c06-old-worker", + "label": "老饭票与新现金的两只手", + "actionDescription": "老工友仍递旧饭票,门外客人同时递现金,旁人忍不住笑他没跟上变化。", + "speech": "老工友把饭菜票往回缩,秦师傅按住旁边的笑声:“规矩刚变,我给您说新办法,别急。”", + "evidence": "付款制度刚变,清楚解释和人工帮助比取笑更能让人顺利完成消费。", + "question": "制度变了就嘲笑仍递饭票的老工友,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "付款制度刚变化,不熟悉新方式很正常;解释和人工帮助能保住人的体面与选择。", + "retryHint": "一个人仍按昨天的办法递饭菜票,是该被取笑,还是该先把新规则说明白?", + "actionAdvice": "先说明现在可用的付款方式,并由工作人员完成一次清楚、耐心的人工协助。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H23", + "eventNumber": 23, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c06-lin", + "label": "碗盖护住的员工饭", + "actionDescription": "林秀兰先给两碗员工饭扣上碗盖,再把错峰轮班纸压在现金盒下。", + "speech": "林秀兰给员工饭扣上碗盖:“饭先盛好,班也排好。零零碎碎尝几口,不能算吃过一顿。”", + "evidence": "员工饭已盛好、吃饭时间有人接班,忙乱中的“先尝两口菜”没有冒充完整一餐。", + "question": "开业再忙也先安排员工轮流坐下吃饭,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "忙乱中尝菜不能长期代替正餐;有饭、有接班时间和可用座位,安排才真正成立。", + "retryHint": "饭盛出来却没人接班、没时间坐下,这顿员工饭真的安排好了吗?", + "actionAdvice": "提前盛好员工饭,写明错峰时间并安排替班,让每个人能坐下完成一餐。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + }, + { + "hotspotId": "S01-H24", + "eventNumber": 24, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c06-qin", + "label": "客人指向第十五桌的手", + "actionDescription": "客人指着第十五桌要拼大桌,秦师傅先挡在桌前,再为客人找别的组合。", + "speech": "客人指向第十五桌,秦师傅挡在前面:“这桌给当班的人坐。我给您并旁边两桌,席面一样摆得开。”", + "evidence": "饭馆对外营业后,这张员工桌依然保持可坐,而不是只挂着“保留”口号。", + "question": "对外营业后仍为员工保留一张能真正坐下吃饭的桌,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "饭馆招待客人,也不能让员工长期失去基本吃饭位置;保留必须在忙时仍可执行。", + "retryHint": "墙上写着“员工桌”,但客人一多就立即撤掉,它还算真正保留吗?", + "actionAdvice": "向客人说明用途并提供其他拼桌方案,同时确保员工桌在营业高峰仍然可坐。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + } + ], + "sceneAsset": "/package-chapter-06/assets/scenes/gui-xiang-1995.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM06", + "chapterId": "S01-C06", + "title": "门牌翻了,位置别丢", + "triggerPosition": "社会客人第一次指向第十五桌、桌子尚未被挪走之前", + "character": { + "id": "qin-zhicheng", + "name": "秦志成" + }, + "sceneText": "“职工食堂”的木牌翻成了“桂香饭馆”。新客人进门,老工友捏着旧饭票,员工的饭还扣在碗盖下面。", + "prompt": "饭馆要往前走,第十五桌怎样继续留下来?", + "playerChoices": [ + { + "choiceId": "S01-EM06-A", + "text": "把十五号桌牌擦亮,留在客人看得见的位置,让老工友和员工都能正常落座。", + "characterFeedback": "秦师傅按住桌沿:“不是给谁开小灶,是谁来了,都别让他站着。”" + }, + { + "choiceId": "S01-EM06-B", + "text": "请林秀兰讲清新的付款办法,也把员工吃饭的班次当众排出来。", + "characterFeedback": "秦师傅收起旧饭票:“新账得讲明白,老情分也不能叫人没地方吃饭。”" + } + ], + "tableEcho": "门牌可以翻面,留给人的座位不能翻没。", + "echoCategory": "生活" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C07", + "chapterNumber": 7, + "title": "桂香饭馆开张", + "year": "1995", + "location": "开张后的桂香饭馆", + "sceneDescription": "开张后的饭馆。少年明远护碗位于左前,赵伯在对面跨桌添饭;唐守安停在门口看表,林秀兰拿饭盒,第十五桌与排班纸在后景保持可用。", + "sceneAlt": "1995年新开张的桂香饭馆,员工饭桌仍靠在墙边", + "narration": "开张忙得脚不沾地。少年明远护住已经盛满的碗,说自己饱了;赵伯却抄起长柄勺:“男孩子能吃才壮!”门口的唐守安问了一句,又看表赶去上课。", + "dialogue": null, + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "爱惜粮食可以从少盛、不够再添开始,不必让孩子吃撑。", + "问了孩子的感受,就要留时间听完。" + ], + "cliffhanger": "三年后的电话问:“五月初八,十桌婚宴,敢不敢接?”秦师傅看向靠墙的第十五桌。", + "people": [ + { + "instanceId": "c07-mingyuan", + "characterId": "tang-mingyuan", + "name": "唐明远", + "title": "中年家人", + "eraLabel": "1995 · 13岁", + "stance": "坐在墙边桌前,双肘收紧", + "gaze": "先看碗,再抬眼看父亲离开的方向", + "action": "把盛满的碗护到胸前,明确说自己已经饱了", + "prop": "大饭碗、作业本", + "layer": 7, + "position": { + "xPercent": 6, + "yPercent": 33, + "widthPercent": 18, + "heightPercent": 52 + }, + "hotspotIds": [ + "S01-H25" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-mingyuan.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H25" + ] + }, + { + "instanceId": "c07-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "1995 · 42岁", + "stance": "从桌对面探身", + "gaze": "只看明远碗底,没有先看孩子表情", + "action": "举长柄勺准备直接添饭,用“男孩子能吃才壮”替孩子决定", + "prop": "长柄添饭勺", + "layer": 6, + "position": { + "xPercent": 30, + "yPercent": 21, + "widthPercent": 18, + "heightPercent": 63 + }, + "hotspotIds": [ + "S01-H26" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H26" + ] + }, + { + "instanceId": "c07-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "1995 · 38岁", + "stance": "一脚已经跨出门,肩膀回转", + "gaze": "先看儿子,随后又落到手表", + "action": "问“还饿不饿”却没等回答完,匆忙赶去上课", + "prop": "手表、旧棕布包、学习资料", + "layer": 5, + "position": { + "xPercent": 54, + "yPercent": 13, + "widthPercent": 15, + "heightPercent": 68 + }, + "hotspotIds": [ + "S01-H27" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H27" + ] + }, + { + "instanceId": "c07-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "1995 · 38岁", + "stance": "站在墙边员工桌侧", + "gaze": "看明远剩饭,也看排班纸上的空档", + "action": "收好少量剩饭,铺平错峰排班纸,让员工桌真正可坐", + "prop": "小饭盒、排班纸", + "layer": 4, + "position": { + "xPercent": 75, + "yPercent": 24, + "widthPercent": 15, + "heightPercent": 59 + }, + "hotspotIds": [ + "S01-H28" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H28" + ] + }, + { + "instanceId": "c07-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "1995 · 41岁", + "stance": "坐在收银台后景", + "gaze": "看第一枚硬币和空白订席单", + "action": "数开张收入,为三年后婚宴电话铺垫", + "prop": "铁皮现金盒、订席单", + "layer": 1, + "position": { + "xPercent": 87, + "yPercent": 8, + "widthPercent": 9, + "heightPercent": 36 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H25", + "eventNumber": 25, + "actorId": "tang-mingyuan", + "actorName": "唐明远", + "actorInstanceId": "c07-mingyuan", + "label": "明远整个人与护碗动作", + "actionDescription": "十三岁的明远双臂护住已经盛满的碗,明明说饱了仍被要求吃到见底。", + "speech": "明远双臂护住满碗:“我真饱了。”林秀兰没有催他见底,只把饭碗先撤下,交给后厨按实际情况处理。", + "evidence": "大碗是成人盛的,孩子已经表达饱足;爱惜粮食可以从一开始少盛做起。", + "question": "孩子说饱了,仍要求他把大碗吃到见底,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "真实饥饱感值得被尊重;成人盛得过多,不应变成孩子必须吃撑的责任。", + "retryHint": "孩子已经明确说饱了,爱惜粮食是否只能靠继续吃到碗底?", + "actionAdvice": "一开始少盛,不够再添;孩子说饱后先停下来,再妥善处理少量剩余食物。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "家庭与儿童", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-mingyuan.jpg" + }, + { + "hotspotId": "S01-H26", + "eventNumber": 26, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c07-zhao", + "label": "桌对面的赵伯与长柄饭勺", + "actionDescription": "赵伯拿长柄饭勺越过桌面,没问明远就又要添一勺。", + "speech": "赵伯的饭勺越过桌面:“男孩子能吃才壮!”明远把碗往怀里收:“我真吃不下了,您别给我添。”", + "evidence": "“男孩子能吃才壮”替代了孩子自己的饥饱感受,也把饭量变成品行考试。", + "question": "用“男孩子能吃才壮”替孩子决定饭量,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "饭量不能证明体格、能力或懂事;关心也需要先询问本人。", + "retryHint": "“我是为你好”能不能代替本人说出的饥饱感受?", + "actionAdvice": "添饭前先问“还要不要”;得到同意再添,拒绝时把饭勺放回去。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H27", + "eventNumber": 27, + "actorId": "tang-shouan", + "actorName": "唐守安", + "actorInstanceId": "c07-tang", + "label": "小唐看表后离开的背影", + "actionDescription": "唐守安问儿子还饿不饿,却边看表边跨出门,答案尚未说完门就合上了。", + "speech": "唐守安问:“还饿不饿?”明远刚抬头,他一看表:“坏了,要迟了。晚上再说。”门合上后,明远才小声说:“我已经饱了。”", + "evidence": "问题本身正确,但没有留出倾听时间,明远的“已经饱了”只落在关门声后。", + "question": "问了“还饿不饿”,却不等孩子答完就走,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "询问只有与等待、回应连在一起,才会让孩子感到自己的表达有效。", + "retryHint": "问题问对了,却没留下听回答的时间,这算真正听见了吗?", + "actionAdvice": "问完先停下手里的安排,让孩子把话说完;暂时不能听时,明确约定并真正回来继续。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-shouan.jpg" + }, + { + "hotspotId": "S01-H28", + "eventNumber": 28, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c07-lin", + "label": "墙边第十五桌与错峰排班纸", + "actionDescription": "林秀兰把第十五桌清空,贴好员工错峰排班纸,让每班都能轮流坐下。", + "speech": "林秀兰把墙边桌清空,压好排班纸:“不是贴个‘员工桌’就算数,谁几点坐下,得有人接他的活。”", + "evidence": "桌子确实可用、时间确实有人替班,关心从一句话变成能执行的安排。", + "question": "开业忙时仍安排员工错峰坐下吃饭,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "可用座位和有人替班的时间同时存在,员工才能真正完成一餐。", + "retryHint": "桌子还在,但没人有时间坐,是否已经实现了“给员工留桌”?", + "actionAdvice": "清空员工桌,写明错峰用餐时间并安排接班人员,营业高峰也照常执行。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + } + ], + "sceneAsset": "/package-chapter-07/assets/scenes/gui-xiang-1995.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM07", + "chapterId": "S01-C07", + "title": "把“我饱了”听完", + "triggerPosition": "唐守安离开、三年后的婚宴电话响起之前", + "character": { + "id": "tang-mingyuan", + "name": "唐明远" + }, + "sceneText": "门已经关上,少年明远仍护着被盛满的大碗。他那句“我已经饱了”,只说给了桌边剩下的人听。", + "prompt": "你愿意怎样把这句话接下去?", + "playerChoices": [ + { + "choiceId": "S01-EM07-A", + "text": "在他旁边坐下,不追问,等他把“我已经饱了”说完。", + "characterFeedback": "明远松开护碗的手:“我不是不懂事,我是真的已经饱了。”" + }, + { + "choiceId": "S01-EM07-B", + "text": "放下添饭勺,问他愿意先少盛一点,还是把没动过的饭另作处理。", + "characterFeedback": "明远把碗推回一点:“那就先少盛。不够的时候,我自己会再添。”" + } + ], + "tableEcho": "问出口的关心,要等到对方把话说完。", + "echoCategory": "成长" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C08", + "chapterNumber": 8, + "title": "四凉八热才叫客气", + "year": "1998", + "location": "桂香饭馆第一场大婚宴", + "sceneDescription": "1998年婚宴将散。左侧圆桌仍过满,右侧主家推回打包盒;前景林秀兰拿复写菜单,深处女炊事员端盒无座,秦师傅正卸铜牌。", + "sceneAlt": "1998年桂香婚宴,过满的转盘与被挤到后厨的员工桌形成对照", + "narration": "圆桌转盘已经摆满,主家仍怕“不够体面”。打包盒被推回,员工桌被挤进后厨。秦师傅说“就这一场”,顺手把十五号铜牌放进现金盒。", + "dialogue": null, + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "体面不等于超量点餐,菜单应说明人数和大致份量。", + "“只借一晚”反复发生,员工没有座位就会变成常态。" + ], + "cliffhanger": "现金盒合上前,铜牌背面的暗红漆与两张破损饭票一闪而过;下一场订席电话又响了。", + "people": [ + { + "instanceId": "c08-host", + "characterId": "wedding-host", + "name": "婚宴主家", + "title": "怕不够体面的客人", + "eraLabel": "1998 · 喜宴主家", + "stance": "坐在转盘外沿又起身招手", + "gaze": "在满桌菜与邻桌客人的眼光之间", + "action": "一面要求继续加菜,一面把打包盒推回去", + "prop": "转盘、加菜单、打包盒", + "layer": 6, + "position": { + "xPercent": 5, + "yPercent": 23, + "widthPercent": 18, + "heightPercent": 61 + }, + "hotspotIds": [ + "S01-H29", + "S01-H30" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H29", + "S01-H30" + ] + }, + { + "instanceId": "c08-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "1998 · 41岁", + "stance": "站在前景菜单与客桌之间", + "gaze": "看只列菜名的复写菜单", + "action": "试图补问大致份量与人数,又被忙乱催着落单", + "prop": "复写菜单、铅笔", + "layer": 7, + "position": { + "xPercent": 31, + "yPercent": 16, + "widthPercent": 15, + "heightPercent": 65 + }, + "hotspotIds": [ + "S01-H31" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H31" + ] + }, + { + "instanceId": "c08-female-cook", + "characterId": "female-cook", + "name": "女炊事员", + "title": "后厨老员工", + "eraLabel": "1998 · 后厨员工", + "stance": "端着饭盒停在被挤窄的门口", + "gaze": "寻找能落碗的座位", + "action": "接过被拒的打包盒,自己却整晚没有地方坐下吃", + "prop": "员工饭盒、打包盒", + "layer": 5, + "position": { + "xPercent": 53, + "yPercent": 29, + "widthPercent": 14, + "heightPercent": 55 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/female-cook.jpg", + "markerTone": "ochre", + "eventIds": [] + }, + { + "instanceId": "c08-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "1998 · 44岁", + "stance": "半蹲在第十五桌短边", + "gaze": "看铜牌,又看仍在响的订席电话", + "action": "卸下铜牌放进现金盒,说“就借这一晚”", + "prop": "十五号铜牌、现金盒", + "layer": 7, + "position": { + "xPercent": 72, + "yPercent": 38, + "widthPercent": 16, + "heightPercent": 47 + }, + "hotspotIds": [ + "S01-H32" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H32" + ] + }, + { + "instanceId": "c08-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "1998 · 41岁", + "stance": "只在婚宴合影边角侧身站立", + "gaze": "看被移向后厨的员工桌", + "action": "不进入本章主对白,身份变化只由不再佩戴卫生员证体现", + "prop": "旧布包", + "layer": 1, + "position": { + "xPercent": 89, + "yPercent": 10, + "widthPercent": 8, + "heightPercent": 36 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H29", + "eventNumber": 29, + "actorId": "wedding-host", + "actorName": "婚宴主家", + "actorInstanceId": "c08-host", + "label": "已经摆满的转盘", + "actionDescription": "婚宴主家看着已经摆满的转盘,仍因怕“显得小气”招手再加几道菜。", + "speech": "主家望着满转盘仍招手:“再添两个硬菜,别让人说小气。”林秀兰问:“先看看十个人已经上了多少?”", + "evidence": "桌面、人数和菜量已经足够,新增菜不是实际需要而是用超量证明体面。", + "question": "只因怕被说小气,再增加几道没人需要的菜,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "招待心意不需要靠超过人数与份量的菜来证明;超量更容易造成浪费。", + "retryHint": "桌面已经摆满,再加菜是实际需要,还是只在替“体面”撑场?", + "actionAdvice": "先核对人数、每份大小、已点品类和实际进度,只补真正缺少的菜。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H30", + "eventNumber": 30, + "actorId": "wedding-host", + "actorName": "婚宴主家", + "actorInstanceId": "c08-host", + "label": "主家推回的打包盒", + "actionDescription": "婚宴主家把员工递来的打包盒推回去,只说带走剩菜“没面子”。", + "speech": "主家把打包盒推回:“喜事哪能拎剩菜走。”女炊事员说:“先问哪些能带,别让面子替食物做决定。”", + "evidence": "半桌菜仍在,是否适合保存需要逐项询问;面子不是拒绝所有安全打包的理由。", + "question": "只因觉得打包没面子,就拒绝带走适合安全保存的剩菜,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "是否适合保存取决于具体食物和存放条件;体面不必靠浪费证明。", + "retryHint": "拒绝打包只是为了面子,是否比逐项确认保存安全更稳妥?", + "actionAdvice": "先向餐厅确认哪些食物适合继续保存;再按该食物相应的保存和复热要求处理,不合适的不要勉强打包。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H31", + "eventNumber": 31, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c08-lin", + "label": "只写菜名的复写菜单", + "actionDescription": "林秀兰拿着只列菜名的复写菜单,想问每道菜大致份量,却被催着赶快落单。", + "speech": "林秀兰把复写菜单铺平,拿铅笔在菜名旁记:“一桌十个人,这盘多大、上几盘,你先给主家说明白。”", + "evidence": "菜单缺少供几人和大致份量,主家无法判断十桌套餐是否已经过量。", + "question": "套餐只列菜名,不说明大致份量和供几人,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "只报菜名,不说明盘量和每桌数量,主家很难判断十桌套餐是否已经过量。", + "retryHint": "复写菜单只写菜名,不说明盘子大小和每桌上几盘,主家能判断是否点多了吗?", + "actionAdvice": "订席时先说清每桌人数、盘量和上菜数量,再决定是否加菜。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + }, + { + "hotspotId": "S01-H32", + "eventNumber": 32, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c08-qin", + "label": "被挤走的员工桌和卸下的铜牌", + "actionDescription": "秦师傅卸下十五号铜牌,把员工桌挤进后厨,嘴上说“就借这一晚”。", + "speech": "秦师傅卸下铜牌:“就借这一晚。”林秀兰停了一拍:“我也说过,就这一场。”", + "evidence": "女炊事员已端盒无座;第一次临时让步没有轮班和替代座位,后来便场场重复。", + "question": "为临时加桌,让员工整晚端碗站着吃,并说“就借这一晚”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "员工整晚端碗站着吃,说明服务安排已经把自己的基本需要挤掉;反复的临时会成为常态。", + "retryHint": "没有替代座位和轮班,“临时借桌”反复发生以后,还只是临时吗?", + "actionAdvice": "保留员工可用座位并安排轮班;确需调整时先落实替代位置和时间,再登记被拆下的旧物。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + } + ], + "sceneAsset": "/package-chapter-08/assets/scenes/gui-xiang-1998.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM08", + "chapterId": "S01-C08", + "title": "热闹也照到后厨", + "triggerPosition": "十五号铜牌落进铁皮盒、下一场订席电话响起之前", + "character": { + "id": "female-cook", + "name": "女炊事员" + }, + "sceneText": "婚宴的笑声还没散,女炊事员端着饭盒站在传菜口。原来属于员工的桌已经被推走,她一时找不到放碗的位置。", + "prompt": "在最忙的时候,你想怎样让她知道自己没有被忘记?", + "playerChoices": [ + { + "choiceId": "S01-EM08-A", + "text": "把后厨唯一的矮凳留给她,约好忙完这一轮就有人来替班。", + "characterFeedback": "女炊事员摸了摸凳面:“凳子在,我就知道这顿饭没把我忘了。”" + }, + { + "choiceId": "S01-EM08-B", + "text": "请秦师傅把自己的饭盒也放到她旁边,最后一道菜后和员工一起坐下。", + "characterFeedback": "女炊事员笑了:“师傅同我们一桌,饭凉一点,也不像是在吃剩下的。”" + } + ], + "tableEcho": "热闹照得到客人,也该照得到端菜的人。", + "echoCategory": "温暖" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C09", + "chapterNumber": 9, + "title": "一杯酒绕了三圈", + "year": "2001", + "location": "桂香饭馆宴席", + "sceneDescription": "2001年宴席四块人物区分开。左前司机老周与钥匙,右后唐守安盖杯端茶,中右赵伯出现不适,下方药盒和个人应急卡归入赵伯区域;林秀兰从侧后递茶。", + "sceneAlt": "2001年饭馆宴席,酒杯、茶杯和后厨旧桌处在不同景深", + "narration": "医院走廊里,医生只说“以后注意管理”。再回到饭桌,别人看赵伯的眼神却先变了:酒杯从司机面前绕到唐守安,再转到他手边。赵伯怕的不是少这一口,而是从此被大家先当成“不能吃的人”。", + "dialogue": "赵伯说:“我怕的不是少吃这一口。我怕的是以后大家一吃饭,第一个想到的就是——赵哥不能吃。”", + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "开车不饮酒,拒酒不等于拒绝感情。", + "聚餐不自行改药;明显不适先停酒、保证安全并按情况求助。" + ], + "cliffhanger": "赵伯稳定后暂坐在后厨旧桌旁。门外响起敲墙声——大厅正在隔新的包间。", + "people": [ + { + "instanceId": "c09-driver", + "characterId": "driver-zhou", + "name": "老周", + "title": "当晚司机", + "eraLabel": "2001 · 当晚司机", + "stance": "坐在靠通道一侧,身体向后避酒", + "gaze": "看桌面车钥匙,再看被推来的杯子", + "action": "用掌心挡住白酒杯,明确一口也不喝", + "prop": "车钥匙、白酒杯、白水", + "layer": 6, + "position": { + "xPercent": 4, + "yPercent": 30, + "widthPercent": 17, + "heightPercent": 53 + }, + "hotspotIds": [ + "S01-H33" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H33" + ] + }, + { + "instanceId": "c09-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "2001 · 44岁", + "stance": "坐在右侧靠门末席", + "gaze": "先看劝酒者,再转向出现不适的赵伯", + "action": "盖住空杯端茶拒酒,随后起身先处理安全而不现场炫技", + "prop": "茶杯、旧布包", + "layer": 7, + "position": { + "xPercent": 28, + "yPercent": 19, + "widthPercent": 16, + "heightPercent": 64 + }, + "hotspotIds": [ + "S01-H34" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H34" + ] + }, + { + "instanceId": "c09-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2001 · 48岁", + "stance": "坐姿开始不稳,一手扶桌", + "gaze": "反应变慢,难以跟上劝酒者", + "action": "药盒与酒杯并置,随后出汗手抖;同一人物承接自行改药与异常识别", + "prop": "药盒、个人应急卡、酒杯", + "layer": 8, + "position": { + "xPercent": 52, + "yPercent": 24, + "widthPercent": 19, + "heightPercent": 60 + }, + "hotspotIds": [ + "S01-H35", + "S01-H36" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H35", + "S01-H36" + ] + }, + { + "instanceId": "c09-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "2001 · 44岁", + "stance": "从侧后跨近桌边", + "gaze": "看赵伯状态与可通行的门口", + "action": "撤走酒杯、递茶并让周围人留出安全空间", + "prop": "茶杯、干净毛巾", + "layer": 5, + "position": { + "xPercent": 76, + "yPercent": 18, + "widthPercent": 14, + "heightPercent": 59 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "indigo", + "eventIds": [] + }, + { + "instanceId": "c09-mingyuan", + "characterId": "tang-mingyuan", + "name": "唐明远", + "title": "中年家人", + "eraLabel": "2001 · 19岁", + "stance": "站在宴席后景替长辈倒酒", + "gaze": "看大人如何劝酒,手势正在迟疑", + "action": "酒壶停在半空,只作代际习惯背景,不遮挡四个热点", + "prop": "酒壶", + "layer": 1, + "position": { + "xPercent": 88, + "yPercent": 7, + "widthPercent": 9, + "heightPercent": 39 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-mingyuan.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H33", + "eventNumber": 33, + "actorId": "driver-zhou", + "actorName": "老周", + "actorInstanceId": "c09-driver", + "label": "司机面前的白酒杯", + "actionDescription": "老周把车钥匙放在桌上,面前仍被推来白酒,旁人劝“就一小口”。", + "speech": "老周按住车钥匙:“车是我开,一口也不碰。给我白水,碰杯照样算数。”", + "evidence": "他当晚要开车,能不能饮酒不应靠杯子大小或自认酒量来侥幸。", + "question": "对要开车的人说“就一小口没事”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "驾车不饮酒,安全不以杯子大小、酒量或同桌起哄来判断。", + "retryHint": "要开车的人能不能用“只喝一小口”或自认酒量好来侥幸?", + "actionAdvice": "明确告诉同桌自己要开车,直接撤下酒杯,换成白水或茶。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮酒与用药安全", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H34", + "eventNumber": 34, + "actorId": "tang-shouan", + "actorName": "唐守安", + "actorInstanceId": "c09-tang", + "label": "靠门末席的唐守安与茶杯", + "actionDescription": "唐守安坐在靠门末席,一手轻盖空酒杯,一手端茶,拒绝后众人不再起哄。", + "speech": "唐守安轻盖空酒杯:“心意我领,酒不喝。茶照样碰。”桌上没有人再把杯子推回来。", + "evidence": "他的茶杯同样参与碰杯,拒酒没有中断情分,桌上也没有人继续用面子施压。", + "question": "清楚拒绝饮酒,其他人也不继续起哄,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "尊重拒绝能同时保住关系与安全;杯中是不是酒,不决定情分深浅。", + "retryHint": "一个人明确拒酒以后,继续拿感情和面子劝他,真的更亲近吗?", + "actionAdvice": "接受对方的拒酒选择,准备白水或茶,碰杯和交谈照常进行。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-shouan.jpg" + }, + { + "hotspotId": "S01-H35", + "eventNumber": 35, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c09-zhao", + "label": "药盒与酒杯", + "actionDescription": "赵伯把药盒和个人应急卡压在酒杯旁,想为了酒局自行停药或改量。", + "speech": "赵伯压低声音:“今儿要陪客,我把药往后挪挪,省得碍事。”唐守安按住药盒:“别自己挪,也别自己加减;先按医生给你的个人方案来。”", + "evidence": "聚餐气氛不能替代个人医嘱;漏服或有疑问需要查看个人方案或联系医生、药师。", + "question": "为了参加酒局,自行停药、补服、加倍、减量或调整胰岛素,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "聚餐气氛不能替代个体化治疗安排;临时自行改药可能带来风险。", + "retryHint": "为了迁就一场酒局,能不能自行停药、补服、加倍、减量或改胰岛素?", + "actionAdvice": "按个人既有医嘱和方案执行;如已漏服或有疑问,查看个人方案并联系医生或药师。", + "medicalEscalation": "如已漏服或有疑问,请查看个人方案,或联系医生、药师。", + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮酒与用药安全" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H36", + "eventNumber": 36, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c09-zhao", + "label": "明显不适的人", + "actionDescription": "赵伯开始出汗、手抖、反应变慢;唐守安已经停酒、移开杯子,并让一两个人留在近处陪伴。", + "speech": "赵伯出汗、手抖、反应变慢。唐守安移开酒杯:“先停酒、留人陪着,别凭样子就说他喝高了。”", + "evidence": "外观不能直接判定醉酒或低血糖;第一步应停酒、移开危险物、保持陪伴,再按意识状态求助。", + "question": "发现出汗、手抖、反应变慢后,先停酒、移开危险物并留下人陪伴,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "这些表现不能仅凭外观确定原因;先停止饮酒、保证安全,并按意识状态采取相应行动。", + "retryHint": "先别急着给原因,看看谁已经把酒杯移开、留下人陪着。", + "actionAdvice": "先停酒、移开危险物、保持陪伴。意识清楚且能够安全吞咽时,按本人已有应急方案处理并尽快联系专业医护;意识不清、抽搐或不能安全吞咽时不喂水、不喂食,立即联系当地院前急救中心或急救电话。", + "medicalEscalation": "意识不清、抽搐或不能安全吞咽时,不喂水、不喂食,立即联系当地院前急救中心/急救电话并持续陪伴。", + "knowledgeTags": [ + "生活场景", + "支持环境", + "饮酒与用药安全" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + } + ], + "sceneAsset": "/package-chapter-09/assets/scenes/gui-xiang-2001.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM09", + "chapterId": "S01-C09", + "title": "别先把我摘出去", + "triggerPosition": "章首30秒记忆片段;四个生活事件完成后进入互动", + "character": { + "id": "zhao-jianguo", + "name": "赵建国" + }, + "openingMemory": { + "maxDurationSeconds": 30, + "title": "三十秒记忆:眼神先变了", + "lines": [ + "医院走廊里,医生只说:“以后注意管理。”画面不展示数值、药物或治疗方案。", + "门一合,切到此后第一次聚餐。有人看见赵建国,筷子停了一下,又下意识把一盘菜往远处挪。", + "赵建国仍坐在原位置,却第一次觉得自己像被从饭桌上轻轻摘了出去。" + ] + }, + "sceneText": "重点不是检查本身,而是从那天以后,别人看他的眼神先变了。", + "prompt": "你愿意怎样让赵伯先说出自己的担心?", + "playerChoices": [ + { + "choiceId": "S01-EM09-A", + "text": "坐到他身边,问:“赵叔,您担心什么?”", + "characterFeedback": "赵伯停了一会儿:“我怕的不是少吃这一口。我怕的是以后大家一吃饭,第一个想到的就是——赵哥不能吃。”" + }, + { + "choiceId": "S01-EM09-B", + "text": "把菜单递回去,问:“今晚您想怎么坐?”", + "characterFeedback": "赵伯把菜单摊开:“那我还坐老位置。该问的我问,该停的,我自己说。”" + } + ], + "tableEcho": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "echoCategory": "成长" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C10", + "chapterNumber": 10, + "title": "后厨里的员工桌", + "year": "2003", + "location": "桂香饭馆后厨", + "sceneDescription": "2003年从传菜口看后厨。旧桌只剩一块空位;女炊事员左前端碗,明远居中被电话拉走,赵伯右侧举添饭勺,秦师傅端饭盒站着,街对面值班牌只作后景。", + "sceneAlt": "2003年桂香饭馆后厨,旧员工桌被菜筐和酒箱占满", + "narration": "旧桌只剩巴掌大一块空位,上面堆着菜筐和酒箱。员工端碗站着吃,明远又被订席电话拉走,冷饭和墙钟一起过了饭点。", + "dialogue": null, + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "长期站着匆忙吃、拖延正餐,需要由排班和可用座位来改变。", + "餐具大小、饭量多少,都不能代表一个人的能力。" + ], + "cliffhanger": "规划红线压过桌脚,最后一张凳子被推走。字幕写着:“五年后,这张图再次展开。”", + "people": [ + { + "instanceId": "c10-female-cook", + "characterId": "female-cook", + "name": "女炊事员", + "title": "后厨老员工", + "eraLabel": "2003 · 后厨员工", + "stance": "端碗站在传菜口左前", + "gaze": "寻找被菜筐占住的空位", + "action": "趁出菜间隙快速站着吃,脚边没有可用凳子", + "prop": "饭碗、抹布", + "layer": 7, + "position": { + "xPercent": 4, + "yPercent": 28, + "widthPercent": 15, + "heightPercent": 57 + }, + "hotspotIds": [ + "S01-H37" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/female-cook.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H37" + ] + }, + { + "instanceId": "c10-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2003 · 50岁", + "stance": "站在桌右侧前倾", + "gaze": "看明远碗里的饭量", + "action": "举大碗和添饭勺,用吃得多替年轻人的能力作证明", + "prop": "大碗、添饭勺", + "layer": 6, + "position": { + "xPercent": 23, + "yPercent": 20, + "widthPercent": 17, + "heightPercent": 64 + }, + "hotspotIds": [ + "S01-H38" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H38" + ] + }, + { + "instanceId": "c10-mingyuan", + "characterId": "tang-mingyuan", + "name": "唐明远", + "title": "中年家人", + "eraLabel": "2003 · 21岁", + "stance": "半坐半起,被电话线拉向另一侧", + "gaze": "看墙钟又转向响起的座机", + "action": "腰身已比十九岁略厚;刚把账本挪开准备吃饭,又因订席电话让冷饭继续拖过饭点", + "prop": "长卷线座机、冷饭、账本", + "layer": 8, + "position": { + "xPercent": 46, + "yPercent": 17, + "widthPercent": 18, + "heightPercent": 68 + }, + "hotspotIds": [ + "S01-H39" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-mingyuan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H39" + ] + }, + { + "instanceId": "c10-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "2003 · 49岁", + "stance": "端着饭盒站在被堆满的旧桌前", + "gaze": "嘴上看员工,眼睛却找不到落碗处", + "action": "说“给自己人留桌”,身体却被酒箱和菜筐逼到通道", + "prop": "饭盒、桌牌", + "layer": 7, + "position": { + "xPercent": 69, + "yPercent": 23, + "widthPercent": 15, + "heightPercent": 61 + }, + "hotspotIds": [ + "S01-H40" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H40" + ] + }, + { + "instanceId": "c10-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "2003 · 46岁", + "stance": "只在街对面窗帘后露过路背影", + "gaze": "看门诊值班牌后继续前行", + "action": "以“唐守安坐诊”牌和旧布包交代合规执业,不进入饭馆冲突", + "prop": "旧布包、值班牌", + "layer": 1, + "position": { + "xPercent": 88, + "yPercent": 10, + "widthPercent": 8, + "heightPercent": 37 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H37", + "eventNumber": 37, + "actorId": "female-cook", + "actorName": "女炊事员", + "actorInstanceId": "c10-female-cook", + "label": "端碗却无座的员工", + "actionDescription": "女炊事员端着碗挤在传菜口,每天趁出菜空隙几口站着吃完。", + "speech": "女炊事员端碗站在传菜口:“等这一锅出完,我再站着扒两口。”桌边明明写着给员工留,却连凳子都没有。", + "evidence": "旧桌被菜筐占满,也没有轮班接手;匆忙站食已从偶发变成长期工作方式。", + "question": "每天都在出菜间隙快速站着吃完,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "长期匆忙、无座和无休息的进食方式,需要从工作安排与可用空间改变,不能只叫个人“注意”。", + "retryHint": "每天都在出菜间隙站着快速吃完,是个人习惯,还是排班和环境没有给人选择?", + "actionAdvice": "清出真实可用的座位,安排替班和完整用餐时间,让员工能坐下完成一餐。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/female-cook.jpg" + }, + { + "hotspotId": "S01-H38", + "eventNumber": 38, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c10-zhao", + "label": "赵伯端来的大碗和添饭勺", + "actionDescription": "赵伯端来大碗和添饭勺,又想用“年轻人能吃才有本事”给明远加饭。", + "speech": "赵伯举起大碗:“男人吃饭,碗小了哪顶事?”明远摊开手掌:“赵叔,我已经饱了。”", + "evidence": "明远已经说饱,餐具大小和饭量都不能代表他的能力或是否肯干活。", + "question": "用碗大、吃得多证明年轻男性有本事,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "餐具和饭量不能代表能力;替人添饭会遮住本人真实饥饱与选择。", + "retryHint": "碗大、吃得多,真的能证明一个年轻人更有本事吗?", + "actionAdvice": "先问本人还要不要;按实际需要选择餐具和份量,不用饭量给能力排名。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H39", + "eventNumber": 39, + "actorId": "tang-mingyuan", + "actorName": "唐明远", + "actorInstanceId": "c10-mingyuan", + "label": "过了饭点仍响的电话、冷饭和墙钟", + "actionDescription": "二十一岁的明远腰身已比少年时略厚;他刚清出吃饭位置,长卷线座机又响,便转身接订席让冷饭继续等。", + "speech": "座机一次次响,明远的饭越放越凉。他终于把听筒放稳:“这通说完,我先把饭吃了。”", + "evidence": "墙钟早过饭点,电话、账本与久坐连续发生;画面记录的是习惯正在累积,不是凭体型诊断疾病。", + "question": "连续久坐接订席,正餐一拖再拖,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "工作、用餐、活动和休息都需要可持续安排;长期拖延不能只靠意志补救。", + "retryHint": "连续久坐接电话、正餐一拖再拖,只靠个人忍着就能长期维持吗?", + "actionAdvice": "给工作设置明确停顿和替接安排,按个人需要保留正常进餐、活动与休息时间。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-mingyuan.jpg" + }, + { + "hotspotId": "S01-H40", + "eventNumber": 40, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c10-qin", + "label": "菜筐、酒箱和被压住的桌牌", + "actionDescription": "秦师傅说“这是给自己人留的桌”,自己端着饭盒却找不到一处能放碗的地方。", + "speech": "秦师傅说“这是给自己人留的桌”,酒箱却压住桌牌,老吕端着碗又从后门退了出去。", + "evidence": "菜筐、酒箱和账本长期压满桌面,口头保留没有形成真实可用的员工座位。", + "question": "口头说“给自己人留桌”,实际长期堆物不能坐,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "环境是否可使用,比墙上标语和口头承诺更能说明一个人的需要有没有被看见。", + "retryHint": "口头说“给自己人留”,实际长期堆物不能坐,这份关心真正落地了吗?", + "actionAdvice": "清空员工桌,安排谁在高峰时替班,并明确这张桌不能长期堆放酒箱和菜筐。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境", + "饮酒与用药安全", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + } + ], + "sceneAsset": "/package-chapter-10/assets/scenes/gui-xiang-2003.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM10", + "chapterId": "S01-C10", + "title": "先让饭盒落桌", + "triggerPosition": "后厨桌面被完全占满、扩建规划红线出现之前", + "character": { + "id": "tang-mingyuan", + "name": "唐明远" + }, + "sceneText": "座机又响了。明远一手抓着长卷线听筒,一手端着已经凉掉的饭盒,桌上连放一只碗的空处都没有。", + "prompt": "你想怎样帮他把这一顿饭重新放回生活里?", + "playerChoices": [ + { + "choiceId": "S01-EM10-A", + "text": "替他记下回电号码,问他想先吃两口,还是先把这通电话说完。", + "characterFeedback": "明远看了一眼墙钟:“那我先吃两口,回头我自己打过去。”" + }, + { + "choiceId": "S01-EM10-B", + "text": "先腾出一块能放饭盒的桌面,陪他等这通电话结束。", + "characterFeedback": "明远把饭盒放稳:“有人等我把电话说完,吃饭就没那么像又一件差事。”" + } + ], + "tableEcho": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "echoCategory": "生活" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C11", + "chapterNumber": 11, + "title": "旧房子要拆了", + "year": "2008", + "location": "翻建前的桂香旧房", + "sceneDescription": "2008年翻建白天主画。展柜在后景,左侧检查旧桌结构,右侧围挡留通道;秦师傅居中悬笔,林秀兰侧后、小满门后、唐守安搬旧物。点击秦师傅才进入夜景保存浮窗。", + "sceneAlt": "2008年翻建前的桂香旧房,桌板、围挡和处置文件等待决定", + "narration": "旧房翻建,秦师傅的笔悬在处置确认上。白天他签了字;夜里,他却把完整桌板包好,把铜牌和钥匙记录一起锁进铁柜。", + "dialogue": null, + "act": "第二幕 · 第十五桌怎样被留下,又怎样被挪走", + "handnote": [ + "怀旧不能替代结构安全检查,旧家具先评估再使用。", + "施工要有围挡和清楚通道;历史证据要记录来源。" + ], + "cliffhanger": "十八年后,小满认出抽屉里的旧饭票夹。她没有擅自拿,只第三次问:“现在能开吗?”", + "people": [ + { + "instanceId": "c11-xiaoman", + "characterId": "qin-xiaoman", + "name": "秦小满", + "title": "饭店经营者", + "eraLabel": "2008 · 15岁", + "stance": "站在玻璃展柜与后门之间", + "gaze": "看展板,也看现实中被搬走的座位", + "action": "擦亮“健康、互助”展板,第一次意识到展板不能替代服务", + "prop": "玻璃展柜、展板抹布", + "layer": 5, + "position": { + "xPercent": 5, + "yPercent": 26, + "widthPercent": 14, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H41" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-xiaoman.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H41" + ] + }, + { + "instanceId": "c11-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "2008 · 54岁", + "stance": "居中坐在临时文件桌前", + "gaze": "从老桌结构检查表移到处置签字处", + "action": "白天既舍不得检查又悬笔签字;人物浮窗切到夜里包板、锁柜", + "prop": "结构检查表、签字笔、旧物处置确认", + "layer": 8, + "position": { + "xPercent": 27, + "yPercent": 15, + "widthPercent": 20, + "heightPercent": 69 + }, + "hotspotIds": [ + "S01-H42", + "S01-H44" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H42", + "S01-H44" + ] + }, + { + "instanceId": "c11-builder", + "characterId": "construction-worker", + "name": "施工负责人", + "title": "翻建现场人员", + "eraLabel": "2008 · 翻建现场", + "stance": "站在右侧围挡入口", + "gaze": "检查人员路线和材料堆放区", + "action": "把材料通道与人员通道分开,指向连续围挡", + "prop": "安全帽、围挡清单", + "layer": 6, + "position": { + "xPercent": 53, + "yPercent": 18, + "widthPercent": 16, + "heightPercent": 65 + }, + "hotspotIds": [ + "S01-H43" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "ochre", + "eventIds": [ + "S01-H43" + ] + }, + { + "instanceId": "c11-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "2008 · 51岁", + "stance": "站在秦师傅侧后,不替他拿笔", + "gaze": "看旧桌,再直看秦师傅", + "action": "问“你不是说这桌不动吗”,等待他自己回答", + "prop": "旧账本、钥匙串", + "layer": 5, + "position": { + "xPercent": 72, + "yPercent": 24, + "widthPercent": 14, + "heightPercent": 58 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "indigo", + "eventIds": [] + }, + { + "instanceId": "c11-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "2008 · 51岁", + "stance": "在最右边缘与员工合抬长凳", + "gaze": "看前方通道是否畅通", + "action": "帮助搬安全旧物,不参与秦师傅的决定", + "prop": "长凳、旧布包", + "layer": 3, + "position": { + "xPercent": 87, + "yPercent": 38, + "widthPercent": 10, + "heightPercent": 45 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H41", + "eventNumber": 41, + "actorId": "qin-xiaoman", + "actorName": "秦小满", + "actorInstanceId": "c11-xiaoman", + "label": "玻璃展柜", + "actionDescription": "年轻小满把“健康、互助”展板擦得发亮,实际通道和服务方式却没有改变。", + "speech": "小满说:“这些字要留下。”林秀兰回她:“字留下了,人坐哪儿,也得留下。”", + "evidence": "理念停在玻璃柜与标语里,没有落到可坐座位、菜单信息和人工服务。", + "question": "只把“健康、互助”做成展板,实际服务没有变化,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "旧物和标语能留住记忆,却不能代替现实中的座位、点餐方式和服务动作。", + "retryHint": "展柜保存的是过去,今天来吃饭的人有没有真能使用的选择?", + "actionAdvice": "把理念落实到可使用的座位、清楚菜单、人工帮助和员工轮班,而不是只做陈列。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-xiaoman.jpg" + }, + { + "hotspotId": "S01-H42", + "eventNumber": 42, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c11-qin", + "label": "老旧桌板", + "actionDescription": "秦师傅因舍不得旧桌,想跳过结构检查直接搬进新饭店继续给客人使用。", + "speech": "秦师傅摸着桌沿:“它撑了几十年。”施工负责人说:“先查清还能不能安全撑今天。”", + "evidence": "桌腿和包边已经老化,怀旧不能替代专业检查、修复与安全加固。", + "question": "不检查结构安全,因怀旧就直接继续给客人使用,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "老家具可能存在松动、开裂或承重风险,感情不能替代安全检查。", + "retryHint": "怀旧很珍贵,可桌腿、木板和连接处是否安全,也得先有人确认。", + "actionAdvice": "先停用,由合适的专业人员检查、清洁和加固,再决定用于展示还是实际使用。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + }, + { + "hotspotId": "S01-H43", + "eventNumber": 43, + "actorId": "construction-worker", + "actorName": "施工负责人", + "actorInstanceId": "c11-builder", + "label": "有围挡的临时通行区", + "actionDescription": "施工负责人把材料堆放区与人员必经路线隔开,并用连续围挡留出清楚通道。", + "speech": "施工负责人说:“人走这一边,材料走那一边。看得见的通道,才真能走。”", + "evidence": "围挡、通道箭头和搬运路线彼此分离,安全安排不是只靠口头喊“小心”。", + "question": "施工材料与人员必经路线分开,并保留清楚通道,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "施工现场把材料区和通行区分开,能减少绊倒、碰撞等风险。", + "retryHint": "看看画里的脚手架、材料和门口:人从哪里安全通过?", + "actionAdvice": "设置连续围挡和醒目标识,保持必经路线畅通;图纸和手续只作为年代证据查看。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H44", + "eventNumber": 44, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c11-qin", + "label": "秦师傅整个人与将落未落的签字笔", + "actionDescription": "秦师傅白天悬笔签旧物处置,夜里又独自包好完整桌板、铜牌和钥匙记录。", + "speech": "秦师傅对门后的小满说:“等我肯说的时候,再开。”笔落下了,话却又收了回去。", + "evidence": "签字、包板、锁柜是两个时段;浮窗中的旧物来源记录使保存行为成为证据链而非神秘藏宝。", + "question": "白天签下旧物处置后,夜里仍保存完整桌板、铜牌与钥匙记录,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "旧桌不能冒险继续使用,但有来源记录的物件可以在安全条件下保存,帮助后续核对历史。", + "retryHint": "别把白天和夜里挤成一件事:先看停用是否安全,再看旧物是否留下了来源记录。", + "actionAdvice": "在授权范围内登记旧物来源,分别保存桌板、铜牌、票据和记录;画面用夜景浮窗呈现第二个时间状态。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + } + ], + "sceneAsset": "/package-chapter-11/assets/scenes/gui-xiang-2008.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM11", + "chapterId": "S01-C11", + "title": "不只把桌板收起来", + "triggerPosition": "白天签字结束、切入夜间藏桌板的小画格时", + "character": { + "id": "qin-zhicheng", + "name": "秦志成" + }, + "sceneText": "夜里,秦师傅把完整旧桌板、铜牌和两枚破票包进油布。他没解释,只把每件东西一件件放进铁柜。", + "prompt": "除了保存旧物,你想请秦师傅再留下些什么?", + "playerChoices": [ + { + "choiceId": "S01-EM11-A", + "text": "在旧物记录上写下年份、来由和当年坐过这张桌的人。", + "characterFeedback": "秦师傅把铅笔字写得很慢:“木头留得住,名字也得留住。”" + }, + { + "choiceId": "S01-EM11-B", + "text": "在新饭店规划旁留一句:员工和晚来的人,必须真有可坐的位置。", + "characterFeedback": "秦师傅合上铁柜:“板子收进柜里不算完。新地方得真有人坐。”" + } + ], + "tableEcho": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "echoCategory": "生活" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C12", + "chapterNumber": 12, + "title": "扫码点出一整桌", + "year": "2026", + "location": "重聚宴服务台与餐桌", + "sceneDescription": "回到2026年。左前赵伯与乐乐围手机,中景林秀兰擦出桌板旧字,右前小满守纸单与人工台,明远拿信息卡;小甄只在服务台协助朗读客观信息,秦师傅仍在后厨深景点头开柜。", + "sceneAlt": "2026年重聚宴,手机点餐、纸质菜单和旧桌板同时进入画面", + "narration": "回到现代,赵伯在手机上不断按加号,乐乐在旁边按减号。小满得到爷爷同意后开柜,旧桌板的红漆、孔位、板字和照片终于彼此对上。", + "dialogue": null, + "act": "第三幕 · 今天怎样重新坐下", + "handnote": [ + "扫码之外要保留大字纸单、人工点餐和正常现金收款。", + "菜品信息说明做法和份量,不等于疗效或健康保证。" + ], + "cliffhanger": "秦师傅把稿子重新卷起,只说:“先上菜。”林秀兰看见末行写着“请晚班的人原谅我”。", + "people": [ + { + "instanceId": "c12-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2026 · 73岁", + "stance": "坐在手机点餐台左前", + "gaze": "盯着菜数和加号", + "action": "十人十四菜仍连续按加号,强调自己会用手机但选择仍需判断", + "prop": "点餐手机、盖碗茶", + "layer": 7, + "position": { + "xPercent": 4, + "yPercent": 28, + "widthPercent": 15, + "heightPercent": 57 + }, + "hotspotIds": [ + "S01-H47" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H47" + ] + }, + { + "instanceId": "c12-lele", + "characterId": "lele", + "name": "乐乐", + "title": "会直问的小孙子", + "eraLabel": "2026 · 10岁", + "stance": "站在赵伯椅边,身体向手机探", + "gaze": "看菜数而不是抢走手机", + "action": "用手指停住减号,问人数与份量", + "prop": "菜单份量卡", + "layer": 8, + "position": { + "xPercent": 20, + "yPercent": 43, + "widthPercent": 13, + "heightPercent": 42 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/lele.jpg", + "markerTone": "jade", + "eventIds": [] + }, + { + "instanceId": "c12-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "2026 · 69岁", + "stance": "俯身在服务推车上的旧桌板旁", + "gaze": "看逐渐显出的铅笔字", + "action": "擦去油灰,把红漆、孔位、板字和旧照片摆成可核证的关系", + "prop": "抹布、旧照片、旧桌板", + "layer": 6, + "position": { + "xPercent": 36, + "yPercent": 22, + "widthPercent": 16, + "heightPercent": 61 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "ochre", + "eventIds": [] + }, + { + "instanceId": "c12-xiaoman", + "characterId": "qin-xiaoman", + "name": "秦小满", + "title": "饭店经营者", + "eraLabel": "2026 · 33岁", + "stance": "站在开柜与服务台之间", + "gaze": "先征询爷爷,再看纸单与人工服务是否到位", + "action": "得到同意后开柜留证;同时铺开纸菜单、人工点餐牌和现金盒", + "prop": "饭票夹钥匙、纸菜单、现金盒", + "layer": 8, + "position": { + "xPercent": 56, + "yPercent": 15, + "widthPercent": 17, + "heightPercent": 69 + }, + "hotspotIds": [ + "S01-H45", + "S01-H46" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-xiaoman.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H45", + "S01-H46" + ] + }, + { + "instanceId": "c12-mingyuan", + "characterId": "tang-mingyuan", + "name": "唐明远", + "title": "中年家人", + "eraLabel": "2026 · 44岁", + "stance": "站在右侧餐桌边", + "gaze": "看中性菜品信息卡", + "action": "多年久坐与应酬后腰腹明显丰厚;此刻又把做法和份量误读成健康保证", + "prop": "菜品信息卡、保温杯", + "layer": 7, + "position": { + "xPercent": 78, + "yPercent": 24, + "widthPercent": 15, + "heightPercent": 59 + }, + "hotspotIds": [ + "S01-H48" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-mingyuan.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H48" + ] + }, + { + "instanceId": "c12-xiaozhen", + "characterId": "xiaozhen", + "name": "小甄", + "title": "甄养堂健康客服", + "eraLabel": "2026 · 约28岁", + "stance": "站在服务台侧后,不靠近旧桌证据区", + "gaze": "看大字纸单和需要朗读帮助的来宾", + "action": "只协助朗读做法、份量等客观信息,不替任何人选择,也不解释谜案", + "prop": "大字提示卡、记录夹", + "layer": 3, + "position": { + "xPercent": 69, + "yPercent": 17, + "widthPercent": 9, + "heightPercent": 49 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/xiaozhen.jpg", + "markerTone": "jade", + "eventIds": [] + }, + { + "instanceId": "c12-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "2026 · 72岁", + "stance": "在最深处后厨门边点头", + "gaze": "看小满手中的钥匙", + "action": "允许开柜后仍擦了两次围裙,没有走出门", + "prop": "白围裙、卷稿", + "layer": 1, + "position": { + "xPercent": 89, + "yPercent": 7, + "widthPercent": 8, + "heightPercent": 37 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "ochre", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H45", + "eventNumber": 45, + "actorId": "qin-xiaoman", + "actorName": "秦小满", + "actorInstanceId": "c12-xiaoman", + "label": "饭票夹钥匙和旧桌板", + "actionDescription": "小满第三次先问爷爷,得到同意才用饭票夹钥匙开柜,并逐项拍照核对。", + "speech": "小满说:“这回能开吗?”秦师傅点头。她又说:“一件一件记,别让一个孔替所有证据说话。”", + "evidence": "红漆、孔位、板字、照片、票据与旧物记录共同吻合,且每项都有来源。", + "question": "征得秦师傅同意后开柜,并用红漆、孔位、板字、照片和票据共同核对,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "单一痕迹可能有多种解释,多项来源清楚的证据相互印证更可靠。", + "retryHint": "一只钉孔能说明多少?再看看旁边还有哪些能互相印证的东西。", + "actionAdvice": "分别拍照并记录桌板、铜牌、票据、照片和旧物记录的来源,不用一个孔位证明全部历史。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-xiaoman.jpg" + }, + { + "hotspotId": "S01-H46", + "eventNumber": 46, + "actorId": "qin-xiaoman", + "actorName": "秦小满", + "actorInstanceId": "c12-xiaoman", + "label": "纸质菜单、人工台和现金盒", + "actionDescription": "小满把大字纸菜单铺在服务台,员工站在人工点餐牌旁,现金盒正常打开。", + "speech": "小满说:“会扫码就扫,想看纸单就看纸单;有人在这儿帮,现金也照常收。”", + "evidence": "扫码、纸单、人工点餐并行可用,现金也不是藏起来的“特殊备用”。", + "question": "在扫码之外保留大字纸单、人工点餐,并保持正常现金收款,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "不同人熟悉的操作方式不同,多种真实可用的入口能减少数字生活带来的排除。", + "retryHint": "想想不会扫码、看不清小字,或只想请服务员帮忙的人,能不能顺利点到菜。", + "actionAdvice": "把纸单和人工入口放在显眼处,保持现金正常收付;AI只读客观信息,不作医疗判断。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-xiaoman.jpg" + }, + { + "hotspotId": "S01-H47", + "eventNumber": 47, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c12-zhao", + "label": "十个人十四道菜", + "actionDescription": "赵伯看十个人已点十四道菜,仍因数字“不吉利”连续按加号。", + "speech": "赵伯说:“十四不好听,再来俩。”乐乐按住加号:“先别拿吉利数当饭量,十个人已经点了十四道。”", + "evidence": "他没有查看每道菜份量和已覆盖品类,只让纪念意义替代真实需要。", + "question": "因“十四不好听”就再加两道菜,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "纪念和重视不需要靠超出人数与份量的菜来证明。", + "retryHint": "先别只数菜名,看看每份供几人、有没有重样、十个人实际需要多少。", + "actionAdvice": "先核对人数、份量、品类和已有菜品;真正不够时再补。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + }, + { + "hotspotId": "S01-H48", + "eventNumber": 48, + "actorId": "tang-mingyuan", + "actorName": "唐明远", + "actorInstanceId": "c12-mingyuan", + "label": "中性菜品信息卡", + "actionDescription": "明远看到“炖、约供二至三人、可选小份”,便把信息卡说成“健康保证”。", + "speech": "明远说:“写得这么健康,多点一份没事。”乐乐问:“它只说怎么做、多大份,咱还没问谁想吃呢,也没说能治病吧?”", + "evidence": "卡片只说明做法和份量,没有疗效承诺,也不能替每个人的身体与治疗安排做决定。", + "question": "看到“烹调方式:炖;本份约供2—3人;可选小份”,就理解成“健康保证”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "做法和份量是帮助选择的客观信息,不代表疗效,也不能替所有人作同一个决定。", + "retryHint": "卡片告诉了哪些事实,又有哪些关于个人身体和治疗的事,它根本没有回答?", + "actionAdvice": "根据人数、份量、整体搭配和个人安排选择;涉及治疗与个体饮食方案时按专业建议执行。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-mingyuan.jpg" + } + ], + "sceneAsset": "/package-chapter-12/assets/scenes/gui-xiang-2026.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM12", + "chapterId": "S01-C12", + "title": "把选择递回去", + "triggerPosition": "菜品信息卡读完、秦师傅解释现代撤桌之前", + "character": { + "id": "zhao-jianguo", + "name": "赵建国" + }, + "sceneText": "手机、AI朗读、大字纸单和人工点餐都摆在桌边。明远想一次替父亲安排妥当,乐乐却停下来等赵伯自己开口。", + "prompt": "信息都在了,最后一步怎样交还给赵伯?", + "playerChoices": [ + { + "choiceId": "S01-EM12-A", + "text": "请赵伯自己选择看纸单、听语音,还是请服务员当面介绍。", + "characterFeedback": "赵伯拿起大字菜单:“让我自己选,才真叫省心。”" + }, + { + "choiceId": "S01-EM12-B", + "text": "请乐乐把信息卡念完,再把菜单放到赵伯手边,等他决定。", + "characterFeedback": "赵伯笑着点点菜单:“乐乐念得明白,最后这一下让我自己点就行。”" + } + ], + "tableEcho": "让人看懂、让人开口,才算把选择递到手里。", + "echoCategory": "温暖" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C13", + "chapterNumber": 13, + "title": "三代人都说“我是为你好”", + "year": "2026", + "location": "重聚宴共同桌与侧桌", + "sceneDescription": "现代共同桌冲突停在动作将变的一刻。乐乐正中护碗,三双公筷从上方撞来;明远右前推菜,小满站侧桌旁握未展开软尺,赵伯从共同桌探身,林秀兰守公筷架。", + "sceneAlt": "2026年共同餐桌,三代人的夹菜动作与隔开的侧桌形成冲突", + "narration": "三双公筷同时伸向乐乐,他赶紧护住碗。赵伯又指着隔开的侧桌,说是专门照顾“病号”。小满没有挪人,先问:“赵伯,您想坐哪儿?”", + "dialogue": null, + "act": "第三幕 · 今天怎样重新坐下", + "handnote": [ + "使用公筷也要尊重本人选择,关心不等于替别人决定。", + "家庭习惯由大人共同创造,不把责任推给孩子。" + ], + "cliffhanger": "秦师傅端起一锅白菜热汤面,终于跨过躲了整晚的后厨门槛。", + "people": [ + { + "instanceId": "c13-lele", + "characterId": "lele", + "name": "乐乐", + "title": "会直问的小孙子", + "eraLabel": "2026 · 10岁", + "stance": "坐在共同桌正中,双臂护碗", + "gaze": "抬头看三双撞在一起的公筷", + "action": "清楚说自己已经饱了,不接受大人轮流添菜", + "prop": "自己的饭碗", + "layer": 9, + "position": { + "xPercent": 8, + "yPercent": 35, + "widthPercent": 17, + "heightPercent": 50 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/lele.jpg", + "markerTone": "cinnabar", + "eventIds": [] + }, + { + "instanceId": "c13-relative", + "characterId": "elder-relative", + "name": "同桌长辈", + "title": "热心夹菜的亲友", + "eraLabel": "2026 · 家庭聚餐", + "stance": "三人从桌对面同时探身", + "gaze": "只看乐乐碗里的空处", + "action": "公筷在碗上方相撞,使用公筷却没有先征求本人意愿", + "prop": "三双公筷", + "layer": 8, + "position": { + "xPercent": 27, + "yPercent": 13, + "widthPercent": 18, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H49" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "jade", + "eventIds": [ + "S01-H49" + ] + }, + { + "instanceId": "c13-mingyuan", + "characterId": "tang-mingyuan", + "name": "唐明远", + "title": "中年家人", + "eraLabel": "2026 · 44岁", + "stance": "坐在乐乐右前侧,身体向孩子倾", + "gaze": "一边阻止赵伯,一边看自己不吃的菜", + "action": "腰腹比青年时明显丰厚;他把菜和管控要求推给孩子,自己的甜饮仍放在手边", + "prop": "成人甜饮、推来的菜盘", + "layer": 8, + "position": { + "xPercent": 48, + "yPercent": 29, + "widthPercent": 17, + "heightPercent": 57 + }, + "hotspotIds": [ + "S01-H51" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-mingyuan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H51" + ] + }, + { + "instanceId": "c13-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2026 · 73岁", + "stance": "从共同桌左侧探身指向远处侧桌", + "gaze": "看自己被挪走的姓名牌", + "action": "用笑话指出“人还没走,牌先走了”,保留被关心者的主动表达", + "prop": "姓名牌、盖碗茶", + "layer": 7, + "position": { + "xPercent": 67, + "yPercent": 25, + "widthPercent": 14, + "heightPercent": 57 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "indigo", + "eventIds": [] + }, + { + "instanceId": "c13-xiaoman", + "characterId": "qin-xiaoman", + "name": "秦小满", + "title": "饭店经营者", + "eraLabel": "2026 · 33岁", + "stance": "站在隔开的侧桌与共同桌之间", + "gaze": "先看姓名牌,再转向赵伯本人", + "action": "从未经询问挪座,转为握住软尺先问本人想坐哪里", + "prop": "姓名牌、尚未展开的软尺", + "layer": 8, + "position": { + "xPercent": 81, + "yPercent": 16, + "widthPercent": 15, + "heightPercent": 66 + }, + "hotspotIds": [ + "S01-H50", + "S01-H52" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-xiaoman.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H50", + "S01-H52" + ] + }, + { + "instanceId": "c13-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "2026 · 69岁", + "stance": "站在公筷架旁侧身让路", + "gaze": "看乐乐而不是看菜盘", + "action": "先问“还要吗”,得到拒绝后把多出的公筷放回架上", + "prop": "公筷架", + "layer": 4, + "position": { + "xPercent": 3, + "yPercent": 9, + "widthPercent": 10, + "heightPercent": 35 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "jade", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H49", + "eventNumber": 49, + "actorId": "elder-relative", + "actorName": "同桌长辈", + "actorInstanceId": "c13-relative", + "label": "三双公筷和乐乐护住的碗", + "actionDescription": "三位长辈的公筷同时伸向乐乐,没问他要不要就在碗上方撞成一团。", + "speech": "赵伯笑:“还排上号了。”乐乐护住碗:“公筷也是筷子,先问我呀!”", + "evidence": "使用公筷解决的是共餐卫生,不会自动取得替别人选菜和份量的同意。", + "question": "不问乐乐就轮流替他夹菜,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "餐具是否公用和本人是否想吃是两件事,关心不能越过明确的饥饱表达。", + "retryHint": "公筷是干净的,可使用公筷,等不等于已经得到本人同意?", + "actionAdvice": "先介绍菜、问“还要不要”,得到同意后再夹,或让孩子自己选择。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "家庭与儿童", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + }, + { + "hotspotId": "S01-H50", + "eventNumber": 50, + "actorId": "qin-xiaoman", + "actorName": "秦小满", + "actorInstanceId": "c13-xiaoman", + "label": "隔开的侧桌与“病号桌”空白牌面", + "actionDescription": "小满担心赵伯,把他的姓名牌先挪到隔开的侧桌,事前没有问本人。", + "speech": "赵伯探身说:“我人还在这儿,牌先被请走了?”小满的手停在半空。", + "evidence": "侧桌与共同桌有明显距离,“病号桌”把关心变成了未经同意的隔离。", + "question": "因担心赵伯,未经商量就把他安排到隔开的桌上,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "支持不等于把患者排除在共同活动之外,未经询问的特殊安排可能让人感到被贴标签。", + "retryHint": "侧桌东西很齐,可赵伯想不想离开大家,有人问过吗?", + "actionAdvice": "先问本人想坐哪里,在共同桌上调整座位、饮品和服务,让需要与社交都能兼顾。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-xiaoman.jpg" + }, + { + "hotspotId": "S01-H51", + "eventNumber": 51, + "actorId": "tang-mingyuan", + "actorName": "唐明远", + "actorInstanceId": "c13-mingyuan", + "label": "明远手里的甜饮", + "actionDescription": "明远刚提醒乐乐少喝甜饮,自己却仍把甜饮举到嘴边;只要求孩子改变,大人没有先做示范。", + "speech": "明远说:“饮料少喝。”乐乐看着他的瓶子:“爸爸,那咱们一起少喝,好吗?”", + "evidence": "采购、份量和示范都由成人创造;只要求儿童控制,是家庭生活中的双重标准。", + "question": "大人提醒孩子少喝,自己却仍举着甜饮,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "家庭饮食环境由成人共同创造,不能把全部责任推给儿童,也不能用双重标准表达关心。", + "retryHint": "先别只管孩子,看看大人自己手里还拿着什么。", + "actionAdvice": "全家一起调整采购、饮品和份量;不评价孩子体型,让孩子表达饥饱并参与选择。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "家庭与儿童" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-mingyuan.jpg" + }, + { + "hotspotId": "S01-H52", + "eventNumber": 52, + "actorId": "qin-xiaoman", + "actorName": "秦小满", + "actorInstanceId": "c13-xiaoman", + "label": "小满整个人与尚未展开的软尺", + "actionDescription": "小满没有立刻挪赵伯,而是握着尚未展开的软尺,先问他想坐在哪里。", + "speech": "小满问:“赵伯,您想坐哪儿?”赵伯把椅子往里收了收:“我还坐大家旁边。”", + "evidence": "询问本人后,软尺才用于调整共同桌空间;她撕掉标签而不是把人移走。", + "question": "不先挪人,先问赵伯想坐哪里,再调整共同桌,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "先询问本人,再调整环境,能同时保留自主感和实际支持。", + "retryHint": "软尺还没展开,先量桌子,还是先听坐桌的人怎么说?", + "actionAdvice": "先问座位、饮品和服务需要;得到回答后再移动椅子、摆放物品或调整桌面。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-xiaoman.jpg" + } + ], + "sceneAsset": "/package-chapter-13/assets/scenes/gui-xiang-2026.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM13", + "chapterId": "S01-C13", + "title": "先问孩子想不想", + "triggerPosition": "小满撕掉侧桌标签、秦师傅端出热汤面之前", + "character": { + "id": "lele", + "name": "乐乐" + }, + "sceneText": "三双公筷终于都停了下来。乐乐还护着自己的碗,等大人第一次不替他说话。", + "prompt": "这一回,家里人怎样让乐乐自己选?", + "playerChoices": [ + { + "choiceId": "S01-EM13-A", + "text": "大家一起放下筷子,只问:“乐乐,你想先吃哪一样?”", + "characterFeedback": "乐乐松开碗:“我不是不要你们关心,我只是想先把自己的话说完。”" + }, + { + "choiceId": "S01-EM13-B", + "text": "给他一只空的小盘,请他自己选一份,再告诉大家为什么。", + "characterFeedback": "乐乐夹了一小份:“我选好了,也会问你们想不想吃,不用替我夹。”" + } + ], + "tableEcho": "这一次,大人终于听孩子把话说完。", + "echoCategory": "成长" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C14", + "chapterNumber": 14, + "title": "秦师傅最后一道老菜", + "year": "2026", + "location": "重聚宴主桌", + "sceneDescription": "秦师傅扶旧桌板居中承担谜底;左下是热汤面,右下林秀兰整理证据,右上唐守安停在门框,左上赵伯端茶起身。四人视线互相连接,但唐守安不越过秦师傅。", + "sceneAlt": "秦师傅端出白菜热汤面,旧桌板和十五号铜牌重新相遇", + "narration": "秦师傅扶着旧桌板,承认自己每一次都有理由,可这些理由加起来,还是让第十五桌消失了。唐大夫没有坐主位,只在普通座上等他把话说完。", + "dialogue": "秦师傅低声说:“每一次我都有道理。可这些道理加起来,就是我把它弄没了。”", + "act": "第三幕 · 今天怎样重新坐下", + "handnote": [ + "老菜可以讲食材、做法和份量,不能包装成“降糖秘方”。", + "专业身份不替当事人表达;用茶碰杯,同样能表达心意。" + ], + "cliffhanger": "秦师傅问:“十五桌还能回来吗?”小满没有回答,先拿软尺重新量空位。", + "people": [ + { + "instanceId": "c14-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "2026 · 72岁", + "stance": "站在中央展示架前,一手扶住旧桌板", + "gaze": "先看老工友,再低头看热汤面", + "action": "端出朴素老菜,明确不是降糖秘方,并完整承认自己一次次挪走桌子", + "prop": "白菜热汤面、旧桌板、卷稿", + "layer": 9, + "position": { + "xPercent": 26, + "yPercent": 12, + "widthPercent": 21, + "heightPercent": 73 + }, + "hotspotIds": [ + "S01-H53" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H53" + ] + }, + { + "instanceId": "c14-lin", + "characterId": "lin-xiulan", + "name": "林秀兰", + "title": "票夹保管人", + "eraLabel": "2026 · 69岁", + "stance": "半蹲在展示架右下", + "gaze": "逐项看证据来源标签", + "action": "把票、牌、漆、孔位、板字和照片组成可追溯线索链", + "prop": "破损票、铜牌、旧照片、记录卡", + "layer": 8, + "position": { + "xPercent": 51, + "yPercent": 39, + "widthPercent": 16, + "heightPercent": 46 + }, + "hotspotIds": [ + "S01-H54" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/lin-xiulan.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H54" + ] + }, + { + "instanceId": "c14-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "2026 · 69岁", + "stance": "停在右上门框,身体朝普通座", + "gaze": "先看秦师傅,随后看末席空位", + "action": "谢绝主位,等待秦师傅说完后才把旧布包放到展示架旁", + "prop": "修补旧布包、老花镜", + "layer": 7, + "position": { + "xPercent": 74, + "yPercent": 9, + "widthPercent": 15, + "heightPercent": 62 + }, + "hotspotIds": [ + "S01-H55" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H55" + ] + }, + { + "instanceId": "c14-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2026 · 73岁", + "stance": "从左侧普通座起身半步", + "gaze": "先招呼唐守安,再回看秦师傅", + "action": "端茶主动碰杯,不再用酒证明旧情分", + "prop": "蓝花盖碗茶", + "layer": 8, + "position": { + "xPercent": 5, + "yPercent": 25, + "widthPercent": 16, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H56" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "indigo", + "eventIds": [ + "S01-H56" + ] + }, + { + "instanceId": "c14-mingyuan", + "characterId": "tang-mingyuan", + "name": "唐明远", + "title": "中年家人", + "eraLabel": "2026 · 44岁", + "stance": "坐在后景父亲将走向的末席旁", + "gaze": "看父亲不再看表", + "action": "把未说完的“我已经饱了”留到四热点后的父子近景", + "prop": "筷子、保温杯", + "layer": 2, + "position": { + "xPercent": 88, + "yPercent": 45, + "widthPercent": 9, + "heightPercent": 35 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-mingyuan.jpg", + "markerTone": "cinnabar", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H53", + "eventNumber": 53, + "actorId": "qin-zhicheng", + "actorName": "秦志成", + "actorInstanceId": "c14-qin", + "label": "白菜热汤面", + "actionDescription": "秦师傅端出白菜热汤面,有人想把这道老菜包装成“祖传降糖面”。", + "speech": "秦师傅说:“就是白菜、面和一锅热汤。讲做法、讲份量,别给它封神。”", + "evidence": "它是当年给晚班工友的一顿热饭,不是治疗,也不会对所有人产生同样作用。", + "question": "因为是老菜,就宣传成“祖传降糖面”,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "一道菜不能治疗糖尿病,也不会对所有人产生相同作用;怀旧不能变成疗效承诺。", + "retryHint": "这锅面能唤起记忆,可一道菜能不能代替治疗、适合所有人?", + "actionAdvice": "只客观介绍食材、做法和份量;具体怎么选按个人情况、既有方案和专业建议。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/qin-zhicheng.jpg" + }, + { + "hotspotId": "S01-H54", + "eventNumber": 54, + "actorId": "lin-xiulan", + "actorName": "林秀兰", + "actorInstanceId": "c14-lin", + "label": "两枚破损票、铜牌、红漆与桌板", + "actionDescription": "林秀兰把破损票、铜牌、红漆桌板、旧照片和记录按来源逐件排开。", + "speech": "林秀兰说:“每件东西只说自己知道的那一段,合起来,故事才站得稳。”", + "evidence": "多项证据相互支持同一历史,但仍需结合发言稿和当事人叙述,不能只靠孔位定案。", + "question": "把票据、铜牌、暗红漆、孔位、板字和照片一起核对,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "多项来源清楚的证据共同指向同一段历史,比单凭一处痕迹可靠。", + "retryHint": "别只盯着一只钉孔,看看每件物品的来源和时间能不能互相接上。", + "actionAdvice": "逐项登记来源和时间,再结合发言稿、调台记录与当事人叙述完成证据链。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "年代线索" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/lin-xiulan.jpg" + }, + { + "hotspotId": "S01-H55", + "eventNumber": 55, + "actorId": "tang-shouan", + "actorName": "唐守安", + "actorInstanceId": "c14-tang", + "label": "门框中的唐大夫", + "actionDescription": "唐守安在门框停下,谢绝主位,走向普通座,让秦师傅把认错的话自己说完。", + "speech": "赵伯喊:“给唐大夫留个座。”唐守安摆手:“普通座就好,先听老秦把话说完。”", + "evidence": "专业身份没有夺走当事人的表达和责任;他只把旧布包放在展示架旁。", + "question": "大家请他坐主位,他选择普通座并让秦师傅自己说完,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "专业身份不应取代当事人的责任和表达,陪伴也不等于替别人发言。", + "retryHint": "唐大夫能帮助理解健康问题,可这次该由谁承担、谁把话说完?", + "actionAdvice": "让当事人完整陈述;专业者坐在同桌提供支持,只有需要时再说明边界或转介。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-shouan.jpg" + }, + { + "hotspotId": "S01-H56", + "eventNumber": 56, + "actorId": "zhao-jianguo", + "actorName": "赵建国", + "actorInstanceId": "c14-zhao", + "label": "赵伯的茶杯", + "actionDescription": "赵伯把茶杯转半圈主动碰杯,也不再劝别人换成酒。", + "speech": "赵伯说:“这回我当个不劝酒、不硬撑的骨干。老伙计,茶也算一杯。”", + "evidence": "茶和白水同样承载祝福,桌上人的选择被接受,没有用杯中酒精衡量情分。", + "question": "用茶参加碰杯,也不再要求别人喝酒,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "拒绝饮酒不等于拒绝关系,心意不取决于杯子里是不是酒。", + "retryHint": "看的是杯中酒,还是人与人之间的心意?", + "actionAdvice": "提前准备白水或茶,碰杯前接受每个人的饮品选择,不追问、不起哄。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/zhao-jianguo.jpg" + } + ], + "sceneAsset": "/package-chapter-14/assets/scenes/gui-xiang-2026.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM14", + "chapterId": "S01-C14", + "title": "这次不看表", + "triggerPosition": "四个正式互动完成、主画翻到父子安静小画格时", + "character": { + "id": "tang-shouan", + "name": "唐守安", + "secondaryCharacter": "唐明远" + }, + "sceneText": "唐守安和明远同高坐下。那只曾让他问完就走的手表,还亮在桌边。", + "prompt": "唐守安怎样让儿子把那句旧话真正说完?", + "playerChoices": [ + { + "choiceId": "S01-EM14-A", + "text": "把手表翻向桌面,什么也不解释,安静等明远说完。", + "characterFeedback": "明远说:“那年我不是故意剩饭。我说过我饱了,可你没听完。”唐守安只回答:“我在听。”" + }, + { + "choiceId": "S01-EM14-B", + "text": "先承认:“我以前问得太快了。这次你慢慢说。”", + "characterFeedback": "唐守安低声说:“我记得我问过,今天才知道我没等答案。”明远看着他:“那你先别改我这句话。”" + } + ], + "tableEcho": "会解决问题的人,也可以学着先听一会儿。", + "echoCategory": "成长" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + }, + { + "chapterId": "S01-C15", + "chapterNumber": 15, + "title": "第十五桌重新开席", + "year": "2026", + "location": "桂香大饭店原址", + "sceneDescription": "回到第一章完全相同的固定轴线机位:第十五桌桌脚落回四个压痕,旧桌板暗红铁包边与原轴线对齐。秦师傅坐在靠门桌首等老吕入席,小满拉椅;赵伯举茶,明远夹菜前先问,唐守安坐末席倾听。小甄和服务员把份量、白水与打包提示变成可点击人物行动;老吕带来的缺口碗只作纪念,不再盛食物。", + "sceneAlt": "与第一章同一固定机位,第十五桌落回原桌脚压痕,晚班员工与三代人共同围桌", + "narration": "同一固定机位里,第十五桌的桌脚落回第一章四个压痕的位置,旧桌板的暗红铁包边也重新对齐。晚班员工刚进门,小满已经拉开椅子;明远的筷子停在半空,先问乐乐还要不要。", + "dialogue": "秦师傅看着老吕带来的缺口碗:“还留着呢?”老吕把它放进透明托架:“留着照相。盛饭,咱用新的。”", + "act": "第三幕 · 今天怎样重新坐下", + "handnote": [ + "清楚的份量、人数和饮品信息,让每个人按需要选择。", + "健康支持留在共同桌上,不隔离患者、不神化食物、不责怪孩子。" + ], + "cliffhanger": "快门按下,旧铜牌“15”重新固定。传菜口还有人探头,小满又拉开几把椅子:“别站着了,热饭给你们留着呢。”", + "people": [ + { + "instanceId": "c15-qin", + "characterId": "qin-zhicheng", + "name": "秦志成", + "title": "秦师傅", + "eraLabel": "2026 · 72岁", + "stance": "坐在第十五桌靠门桌首,身体朝向门口", + "gaze": "第一眼认出老吕手里的缺口碗,再看桌边空椅", + "action": "把完好碗筷推到空位前,等老吕坐稳才招呼开席", + "prop": "长柄饭勺、十五号铜牌、完好碗筷", + "layer": 7, + "position": { + "xPercent": 1, + "yPercent": 14, + "widthPercent": 11, + "heightPercent": 54 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/qin-zhicheng.jpg", + "markerTone": "cinnabar", + "eventIds": [] + }, + { + "instanceId": "c15-xiaozhen", + "characterId": "xiaozhen", + "name": "小甄", + "title": "甄养堂健康客服", + "eraLabel": "2026 · 约28岁", + "stance": "站在邻里桌左前的服务台旁", + "gaze": "看三种餐盘和伸手取水的人", + "action": "摆出真实可选的普通、小、半份,并把白水放在容易取得处", + "prop": "三种份量餐盘、白水壶、记录夹", + "layer": 8, + "position": { + "xPercent": 13.5, + "yPercent": 20, + "widthPercent": 11, + "heightPercent": 64 + }, + "hotspotIds": [ + "S01-H57", + "S01-H58" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/xiaozhen.jpg", + "markerTone": "jade", + "eventIds": [ + "S01-H57", + "S01-H58" + ] + }, + { + "instanceId": "c15-mingyuan", + "characterId": "tang-mingyuan", + "name": "唐明远", + "title": "中年家人", + "eraLabel": "2026 · 44岁", + "stance": "坐在长桌左中,公筷停在半空", + "gaze": "先看乐乐的脸,再看孩子自己指的菜", + "action": "夹菜前先问,把公筷递给乐乐自己选择", + "prop": "公筷、保温杯", + "layer": 9, + "position": { + "xPercent": 26, + "yPercent": 33, + "widthPercent": 11, + "heightPercent": 52 + }, + "hotspotIds": [ + "S01-H59" + ], + "clickRole": "可点击人物", + "portrait": "/assets/characters/tang-mingyuan.jpg", + "markerTone": "ochre", + "eventIds": [ + "S01-H59" + ] + }, + { + "instanceId": "c15-xiaoman", + "characterId": "qin-xiaoman", + "name": "秦小满", + "title": "饭店经营者", + "eraLabel": "2026 · 33岁", + "stance": "站在长桌右侧拉开一把椅子", + "gaze": "看刚忙完晚市、准备进门的员工", + "action": "让来晚的人先坐下,把邻里桌从展品变成真正使用的座位", + "prop": "椅子、软尺收纳袋", + "layer": 8, + "position": { + "xPercent": 38.5, + "yPercent": 20, + "widthPercent": 11, + "heightPercent": 64 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/qin-xiaoman.jpg", + "markerTone": "indigo", + "eventIds": [] + }, + { + "instanceId": "c15-worker", + "characterId": "service-worker", + "name": "饭店员工", + "title": "当班服务人员", + "eraLabel": "2026 · 晚市当班", + "stance": "端着可分类的打包盒站在传菜口", + "gaze": "看顾客留下的菜和保存提示", + "action": "逐项询问是否打包,说明哪些适合保存及相应处理方式", + "prop": "打包盒、保存提示卡", + "layer": 7, + "position": { + "xPercent": 51, + "yPercent": 25, + "widthPercent": 11, + "heightPercent": 58 + }, + "hotspotIds": [ + "S01-H60" + ], + "clickRole": "可点击人物", + "portrait": "", + "markerTone": "cinnabar", + "eventIds": [ + "S01-H60" + ] + }, + { + "instanceId": "c15-zhao", + "characterId": "zhao-jianguo", + "name": "赵建国", + "title": "赵伯/劳动骨干", + "eraLabel": "2026 · 73岁", + "stance": "坐在桌的右中位置举茶", + "gaze": "看新进门的晚班工友", + "action": "用茶代酒,不劝酒、不硬撑,也不替别人盛饭", + "prop": "蓝花盖碗茶", + "layer": 8, + "position": { + "xPercent": 63.5, + "yPercent": 32, + "widthPercent": 11, + "heightPercent": 52 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/zhao-jianguo.jpg", + "markerTone": "jade", + "eventIds": [] + }, + { + "instanceId": "c15-tang", + "characterId": "tang-shouan", + "name": "唐守安", + "title": "唐侦探/唐大夫", + "eraLabel": "2026 · 69岁", + "stance": "安静坐在最右末席", + "gaze": "看明远说话,不再瞥手表", + "action": "把手表翻向桌面,完整听儿子和乐乐说完", + "prop": "旧布包、翻面的手表", + "layer": 5, + "position": { + "xPercent": 76, + "yPercent": 34, + "widthPercent": 11, + "heightPercent": 50 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "/assets/characters/tang-shouan.jpg", + "markerTone": "ochre", + "eventIds": [] + }, + { + "instanceId": "c15-old-lv", + "characterId": "old-lv", + "name": "老吕", + "title": "晚班钳工", + "eraLabel": "2026 · 老工友", + "stance": "从门口与晚班员工一起进入", + "gaze": "看重新固定的十五号铜牌", + "action": "双手托着旧缺口碗走向座位;合影后把碗放回安全托架,正式用餐改用完好餐具", + "prop": "纸请柬、左侧缺口搪瓷碗", + "layer": 2, + "position": { + "xPercent": 88.5, + "yPercent": 12, + "widthPercent": 10, + "heightPercent": 44 + }, + "hotspotIds": [], + "clickRole": "氛围人物", + "portrait": "", + "markerTone": "indigo", + "eventIds": [] + } + ], + "events": [ + { + "hotspotId": "S01-H57", + "eventNumber": 57, + "actorId": "xiaozhen", + "actorName": "小甄", + "actorInstanceId": "c15-xiaozhen", + "label": "普通份、小份、半份", + "actionDescription": "小甄把普通份、小份、半份三种实物餐盘并排摆好,并标明建议用餐人数。", + "speech": "小甄说:“先看几个人、每份多大。少点不够再添,比猜一桌需要多少更踏实。”", + "evidence": "三种份量都能真实下单,不是菜单上写了“小份”却现场无法选择。", + "question": "菜单提供多种份量并写建议用餐人数,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "清楚的份量信息和多种规格让每个人更容易按人数、饥饱和个人安排选择。", + "retryHint": "选择多是不是等于要求所有人都吃小份?再看看它真正增加了什么。", + "actionAdvice": "点餐前先看份量和建议人数;选择适合当下需要的规格,不把小份变成统一要求。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "饮食行为" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/xiaozhen.jpg" + }, + { + "hotspotId": "S01-H58", + "eventNumber": 58, + "actorId": "xiaozhen", + "actorName": "小甄", + "actorInstanceId": "c15-xiaozhen", + "label": "白水壶与分开的其他饮品区", + "actionDescription": "小甄把白水壶放在伸手可取处,酒和甜饮另行陈列,等本人询问再选择。", + "speech": "小甄说:“白水先放手边,其他饮品看清信息、问过本人,再决定要不要。”", + "evidence": "白水成为容易取得的默认选项,其他饮品没有被自动摆到每个人面前。", + "question": "默认让白水容易取得,酒和甜饮由本人选择,不替所有人自动摆上,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "让白水容易取得、其他饮品由本人选择,既方便也能减少强推和起哄。", + "retryHint": "桌上默认出现什么,会不会悄悄影响每个人的选择?", + "actionAdvice": "先提供白水;需要其他饮品时询问本人,并客观查看配料和添加糖等信息。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/xiaozhen.jpg" + }, + { + "hotspotId": "S01-H59", + "eventNumber": 59, + "actorId": "tang-mingyuan", + "actorName": "唐明远", + "actorInstanceId": "c15-mingyuan", + "label": "明远停在半空的筷子", + "actionDescription": "明远的公筷停在半空,没有直接落进乐乐碗里,而是先问还要不要。", + "speech": "明远问:“这个还要吗?”乐乐点头:“要一点,我自己夹。”明远把公筷递过去。", + "evidence": "他把关心改成询问;得到同意再夹,或者让乐乐自己选择,都保留了本人决定。", + "question": "夹菜前先问乐乐还要不要,这样更稳妥吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "safer", + "reason": "先询问能尊重饥饱和自主选择,也让关心不再变成压力。", + "retryHint": "关心是先把菜放进碗里,还是先给对方回答的机会?", + "actionAdvice": "先问“还要吗”“想自己夹吗”;得到同意后再帮忙,或让本人自己选择。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "行为判断", + "家庭与儿童" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "/assets/characters/tang-mingyuan.jpg" + }, + { + "hotspotId": "S01-H60", + "eventNumber": 60, + "actorId": "service-worker", + "actorName": "饭店员工", + "actorInstanceId": "c15-worker", + "label": "打包盒与保存提示", + "actionDescription": "饭店员工没有把所有剩菜一股脑装盒,而是先逐项说明哪些适合保存和怎样处理。", + "speech": "员工问:“这些要带走吗?我先把能不能保存、怎么再加热给您说清。”", + "evidence": "不同食物的保存条件不同;合理点餐优先,需要打包时还要询问本人和餐厅安全提示。", + "question": "所有剩菜不分情况都必须打包,这样妥当吗?", + "options": [ + { + "value": "safer", + "label": "这样更稳妥" + }, + { + "value": "caution", + "label": "这个行为要注意" + } + ], + "correctAnswer": "caution", + "reason": "不同食物的保存条件和安全风险不同,打包不能替代合理点餐,也不能忽略食品安全。", + "retryHint": "珍惜食物很重要,可每一种剩菜都适合继续保存吗?", + "actionAdvice": "先按需点餐;需要打包时询问餐厅是否适合保存、怎样冷藏和再加热,不适合的不要勉强带走。", + "medicalEscalation": null, + "knowledgeTags": [ + "生活场景", + "支持环境" + ], + "reviewStatus": "draft", + "version": "4.1-emotional-refinement", + "actorPortrait": "" + } + ], + "sceneAsset": "/package-chapter-15/assets/scenes/gui-xiang-2026.jpg", + "emotionMoment": { + "emotionMomentId": "S01-EM15", + "chapterId": "S01-C15", + "title": "人坐回来了", + "triggerPosition": "四项正式互动完成、最终合影快门落下之前", + "character": { + "id": "old-lv", + "name": "老吕", + "secondaryCharacter": "秦志成" + }, + "visualClosure": { + "camera": "回到第一章完全相同的固定轴线机位", + "tablePosition": "第十五桌桌脚落回第一章四个新压痕的位置", + "objectAlignment": "旧桌板暗红铁包边与同一桌脚轴线对应", + "peopleClosure": "赵伯不再站在十四桌与十六桌之间;第十五桌已经坐满,仍为晚班员工留着一把可拉开的椅子", + "bowlSafety": "缺口搪瓷碗只作纪念物,不盛放食物;正式用餐使用完好餐具" + }, + "sceneText": "同一机位里,旧桌板的暗红铁包边对准第一章的桌脚压痕。第十五桌坐满了人,老吕也带来了那只缺口搪瓷碗。", + "prompt": "这只旧碗怎样留在今天的第十五桌边?", + "playerChoices": [ + { + "choiceId": "S01-EM15-A", + "text": "把旧碗放进透明纪念托架,在老吕座位前另摆一只完好的新碗。", + "characterFeedback": "秦师傅看着旧碗:“还留着呢?”老吕把它放稳:“留着照相。吃饭,我用新的。”" + }, + { + "choiceId": "S01-EM15-B", + "text": "请老吕拿着旧碗拍完合影,再把它送回展柜,用完好餐具一起吃饭。", + "characterFeedback": "秦师傅替他托住碗底:“先送回展柜?”老吕点头:“新碗盛饭,旧碗留个念想。”" + } + ], + "tableEcho": "桌子回来了,人也都坐回来了。", + "echoCategory": "温暖" + }, + "audio": { + "status": "reserved", + "label": "本回音频位置已预留", + "note": "文字游戏可完整游玩;正式配音在音频支线验收后直接替换。", + "src": "", + "durationSeconds": 0 + } + } + ] +} diff --git a/TongjiUniApp/native/tang-detective/package-audio-c01-a/data/audioPages.js b/TongjiUniApp/native/tang-detective/package-audio-c01-a/data/audioPages.js new file mode 100644 index 0000000..b8d55c7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-c01-a/data/audioPages.js @@ -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', + }), +}) diff --git a/TongjiUniApp/native/tang-detective/package-audio-c01-a/pages/player/player.js b/TongjiUniApp/native/tang-detective/package-audio-c01-a/pages/player/player.js new file mode 100644 index 0000000..e48ec1e --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-c01-a/pages/player/player.js @@ -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() + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-audio-c01-a/pages/player/player.json b/TongjiUniApp/native/tang-detective/package-audio-c01-a/pages/player/player.json new file mode 100644 index 0000000..ea2c8c0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-c01-a/pages/player/player.json @@ -0,0 +1,3 @@ +{ + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-audio-c01-a/pages/player/player.wxml b/TongjiUniApp/native/tang-detective/package-audio-c01-a/pages/player/player.wxml new file mode 100644 index 0000000..573995d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-c01-a/pages/player/player.wxml @@ -0,0 +1,63 @@ + + + + + 第01回 · 有声夹页 + {{pageNumber}}/8 + + + + + + + 桂香故事 + + + + {{title}} + {{caption}} + + + + {{currentLabel}} / {{durationLabel}} + + + + + + + + + + 语速 + + + + {{error}} + + + + diff --git a/TongjiUniApp/native/tang-detective/package-audio-c01-a/pages/player/player.wxss b/TongjiUniApp/native/tang-detective/package-audio-c01-a/pages/player/player.wxss new file mode 100644 index 0000000..59cbace --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-c01-a/pages/player/player.wxss @@ -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; } +} diff --git a/TongjiUniApp/native/tang-detective/package-audio-c01-b/data/audioPages.js b/TongjiUniApp/native/tang-detective/package-audio-c01-b/data/audioPages.js new file mode 100644 index 0000000..5f6880f --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-c01-b/data/audioPages.js @@ -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', + }), +}) diff --git a/TongjiUniApp/native/tang-detective/package-audio-c01-b/pages/player/player.js b/TongjiUniApp/native/tang-detective/package-audio-c01-b/pages/player/player.js new file mode 100644 index 0000000..e48ec1e --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-c01-b/pages/player/player.js @@ -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() + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-audio-c01-b/pages/player/player.json b/TongjiUniApp/native/tang-detective/package-audio-c01-b/pages/player/player.json new file mode 100644 index 0000000..ea2c8c0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-c01-b/pages/player/player.json @@ -0,0 +1,3 @@ +{ + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-audio-c01-b/pages/player/player.wxml b/TongjiUniApp/native/tang-detective/package-audio-c01-b/pages/player/player.wxml new file mode 100644 index 0000000..573995d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-c01-b/pages/player/player.wxml @@ -0,0 +1,63 @@ + + + + + 第01回 · 有声夹页 + {{pageNumber}}/8 + + + + + + + 桂香故事 + + + + {{title}} + {{caption}} + + + + {{currentLabel}} / {{durationLabel}} + + + + + + + + + + 语速 + + + + {{error}} + + + + diff --git a/TongjiUniApp/native/tang-detective/package-audio-c01-b/pages/player/player.wxss b/TongjiUniApp/native/tang-detective/package-audio-c01-b/pages/player/player.wxss new file mode 100644 index 0000000..59cbace --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-c01-b/pages/player/player.wxss @@ -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; } +} diff --git a/TongjiUniApp/native/tang-detective/package-audio-player/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-audio-player/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-player/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-audio-player/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-audio-player/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-player/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-audio-player/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-audio-player/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-player/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-audio-player/pages/player/player.js b/TongjiUniApp/native/tang-detective/package-audio-player/pages/player/player.js new file mode 100644 index 0000000..cebc842 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-player/pages/player/player.js @@ -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() + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-audio-player/pages/player/player.json b/TongjiUniApp/native/tang-detective/package-audio-player/pages/player/player.json new file mode 100644 index 0000000..ea2c8c0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-player/pages/player/player.json @@ -0,0 +1,3 @@ +{ + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-audio-player/pages/player/player.wxml b/TongjiUniApp/native/tang-detective/package-audio-player/pages/player/player.wxml new file mode 100644 index 0000000..a389e05 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-player/pages/player/player.wxml @@ -0,0 +1,73 @@ + + + + + {{chapterLabel}} · 有声夹页 + {{pageNumber}}/8 + + + + + + + 桂香里的有声夹页 + 桂香里的这一页 + + {{chapterLabel}} + {{pageLabel}} + 声音单独下载,画面仍留在原来的连环画里。 + 桂香故事 + + + + + 听这一页 + 点“开始听”后才下载并播放;来电话或离开页面会暂停,不会自己续播。 + + + + {{currentLabel}} / {{durationLabel}} + + + + + + + + + + 语速 + + + + + {{error}} + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-audio-player/pages/player/player.wxss b/TongjiUniApp/native/tang-detective/package-audio-player/pages/player/player.wxss new file mode 100644 index 0000000..0cd39c0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-player/pages/player/player.wxss @@ -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; } +} diff --git a/TongjiUniApp/native/tang-detective/package-audio-player/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-audio-player/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-player/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-audio-player/utils/chapterRoute.js b/TongjiUniApp/native/tang-detective/package-audio-player/utils/chapterRoute.js new file mode 100644 index 0000000..e5f13b3 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-audio-player/utils/chapterRoute.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-02/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-chapter-02/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-02/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-02/data/playableVisualPolicy.js b/TongjiUniApp/native/tang-detective/package-chapter-02/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-02/data/playableVisualPolicy.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-02/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/package-chapter-02/data/productionComicPages.js new file mode 100644 index 0000000..703d972 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-02/data/productionComicPages.js @@ -0,0 +1,2 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = {} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-02/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-02/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-02/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-02/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-02/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-02/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapter.js b/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapter.json b/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapter.wxml b/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapter.wxss b/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapterLayout.js b/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapterPages.js b/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-02/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-02/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-chapter-02/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-02/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-02/utils/comicPageModel.js b/TongjiUniApp/native/tang-detective/package-chapter-02/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-02/utils/comicPageModel.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-02/utils/comicReaderState.js b/TongjiUniApp/native/tang-detective/package-chapter-02/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-02/utils/comicReaderState.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-03/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-chapter-03/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-03/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-03/data/playableVisualPolicy.js b/TongjiUniApp/native/tang-detective/package-chapter-03/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-03/data/playableVisualPolicy.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-03/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/package-chapter-03/data/productionComicPages.js new file mode 100644 index 0000000..703d972 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-03/data/productionComicPages.js @@ -0,0 +1,2 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = {} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-03/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-03/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-03/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-03/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-03/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-03/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapter.js b/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapter.json b/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapter.wxml b/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapter.wxss b/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapterLayout.js b/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapterPages.js b/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-03/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-03/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-chapter-03/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-03/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-03/utils/comicPageModel.js b/TongjiUniApp/native/tang-detective/package-chapter-03/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-03/utils/comicPageModel.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-03/utils/comicReaderState.js b/TongjiUniApp/native/tang-detective/package-chapter-03/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-03/utils/comicReaderState.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-04/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-chapter-04/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-04/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-04/data/playableVisualPolicy.js b/TongjiUniApp/native/tang-detective/package-chapter-04/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-04/data/playableVisualPolicy.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-04/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/package-chapter-04/data/productionComicPages.js new file mode 100644 index 0000000..fc0c327 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-04/data/productionComicPages.js @@ -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": "" + } + ] + } +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-04/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-04/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-04/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-04/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-04/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-04/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapter.js b/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapter.json b/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapter.wxml b/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapter.wxss b/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapterLayout.js b/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapterPages.js b/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-04/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-04/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-chapter-04/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-04/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-04/utils/comicPageModel.js b/TongjiUniApp/native/tang-detective/package-chapter-04/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-04/utils/comicPageModel.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-04/utils/comicReaderState.js b/TongjiUniApp/native/tang-detective/package-chapter-04/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-04/utils/comicReaderState.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-05/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-chapter-05/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-05/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-05/data/playableVisualPolicy.js b/TongjiUniApp/native/tang-detective/package-chapter-05/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-05/data/playableVisualPolicy.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-05/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/package-chapter-05/data/productionComicPages.js new file mode 100644 index 0000000..703d972 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-05/data/productionComicPages.js @@ -0,0 +1,2 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = {} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-05/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-05/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-05/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-05/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-05/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-05/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapter.js b/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapter.json b/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapter.wxml b/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapter.wxss b/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapterLayout.js b/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapterPages.js b/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-05/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-05/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-chapter-05/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-05/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-05/utils/comicPageModel.js b/TongjiUniApp/native/tang-detective/package-chapter-05/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-05/utils/comicPageModel.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-05/utils/comicReaderState.js b/TongjiUniApp/native/tang-detective/package-chapter-05/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-05/utils/comicReaderState.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-06/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-chapter-06/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-06/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-06/data/playableVisualPolicy.js b/TongjiUniApp/native/tang-detective/package-chapter-06/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-06/data/playableVisualPolicy.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-06/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/package-chapter-06/data/productionComicPages.js new file mode 100644 index 0000000..aa0e120 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-06/data/productionComicPages.js @@ -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": "第一桌社会客人进门,指着靠门的第十五桌:“这张给我们拼个大桌。”" + } + ] + } +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-06/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-06/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-06/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-06/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-06/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-06/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapter.js b/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapter.json b/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapter.wxml b/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapter.wxss b/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapterLayout.js b/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapterPages.js b/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-06/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-06/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-chapter-06/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-06/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-06/utils/comicPageModel.js b/TongjiUniApp/native/tang-detective/package-chapter-06/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-06/utils/comicPageModel.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-06/utils/comicReaderState.js b/TongjiUniApp/native/tang-detective/package-chapter-06/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-06/utils/comicReaderState.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-07/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-chapter-07/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-07/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-07/data/playableVisualPolicy.js b/TongjiUniApp/native/tang-detective/package-chapter-07/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-07/data/playableVisualPolicy.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-07/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/package-chapter-07/data/productionComicPages.js new file mode 100644 index 0000000..9290c34 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-07/data/productionComicPages.js @@ -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": "三年后的电话问:“五月初八,十桌婚宴,敢不敢接?”秦师傅看向靠墙的第十五桌。" + } + ] + } +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-07/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-07/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-07/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-07/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-07/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-07/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapter.js b/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapter.json b/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapter.wxml b/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapter.wxss b/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapterLayout.js b/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapterPages.js b/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-07/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-07/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-chapter-07/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-07/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-07/utils/comicPageModel.js b/TongjiUniApp/native/tang-detective/package-chapter-07/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-07/utils/comicPageModel.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-07/utils/comicReaderState.js b/TongjiUniApp/native/tang-detective/package-chapter-07/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-07/utils/comicReaderState.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-08/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-chapter-08/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-08/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-08/data/playableVisualPolicy.js b/TongjiUniApp/native/tang-detective/package-chapter-08/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-08/data/playableVisualPolicy.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-08/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/package-chapter-08/data/productionComicPages.js new file mode 100644 index 0000000..2a6c248 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-08/data/productionComicPages.js @@ -0,0 +1,184 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C08": { + "pages": [ + { + "pageId": "S01-C08-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P01-title-v1.jpg", + "caption": "第一场婚宴热闹开席,第十五桌却在掌声里被推向后厨。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MT000", + "S01-C08-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P02-opening-ensemble-v1.jpg", + "caption": "主家怕桌上不够体面,秀兰翻看复写菜单;女炊事员端着饭盒,秦师傅正挪员工桌。", + "secondaryCaption": "", + "interactionPrompt": "先看看:谁在顾客桌的体面,谁正失去坐下吃饭的位置。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MS001", + "S01-C08-MS002", + "S01-C08-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P03", + "type": "event", + "eventId": "S01-H29", + "emotionMomentId": "", + "assetName": "S01-C08-P03-H29-extra-dishes-v1.jpg", + "caption": "菜还没上齐,转盘已经没有落筷子的空;主家仍怕显得小气,隔着满桌招手加菜。", + "secondaryCaption": "", + "interactionPrompt": "只因怕被说小气,再增加几道没人需要的菜,这样妥当吗?", + "shortDialogue": "主家说:“四凉八热才像样,再添两个硬菜。”林秀兰问:“先看看十个人吃到哪儿了?”", + "hotspot": { + "x": 27, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS002", + "S01-C08-MS003", + "S01-C08-MS004", + "S01-C08-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P04", + "type": "event", + "eventId": "S01-H30", + "emotionMomentId": "", + "assetName": "S01-C08-P04-H30-rejected-box-v1.jpg", + "caption": "宾客渐散,桌上还剩半桌;主家只因觉得没面子,把女炊事员递来的打包盒推回。", + "secondaryCaption": "", + "interactionPrompt": "只因觉得打包没面子,就拒绝带走适合安全保存的剩菜,这样妥当吗?", + "shortDialogue": "主家说:“喜事哪能拎剩菜走。”女炊事员答:“先问哪些能带,别让面子替食物做决定。”", + "hotspot": { + "x": 28, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P05", + "type": "event", + "eventId": "S01-H31", + "emotionMomentId": "", + "assetName": "S01-C08-P05-H31-menu-gaps-v1.jpg", + "caption": "秀兰把复写菜单翻过来,纸上只有菜名,没有人数,也没有一盘大约够几个人。", + "secondaryCaption": "", + "interactionPrompt": "套餐只列菜名,不说明大致份量和供几人,这样妥当吗?", + "shortDialogue": "林秀兰说:“光有菜名,不知道多大盘,客人怎么知道够不够?”", + "hotspot": { + "x": 22, + "y": 0, + "w": 61, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS008", + "S01-C08-MS009", + "S01-C08-MS010" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P06", + "type": "event", + "eventId": "S01-H32", + "emotionMomentId": "", + "assetName": "S01-C08-P06-H32-removed-plaque-v1.jpg", + "caption": "客桌加了,员工的座位却没了。秦师傅说:“就借这一晚。”", + "secondaryCaption": "", + "interactionPrompt": "为临时加桌,让员工整晚端碗站着吃,并说“就借这一晚”,这样妥当吗?", + "shortDialogue": "秦师傅说:“头一场席,就借一晚。”林秀兰停了一拍:“我也说过,就这一场。”", + "hotspot": { + "x": 23, + "y": 5, + "w": 44, + "h": 90 + }, + "audioCueIds": [ + "S01-C08-MS006", + "S01-C08-MS007", + "S01-C08-MS012", + "S01-C08-MS013" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM08", + "assetName": "S01-C08-P07-EM08-back-kitchen-light-v1.jpg", + "caption": "婚宴笑声还没散,女炊事员端着饭盒站在传菜口,一时找不到放碗的位置。", + "secondaryCaption": "", + "interactionPrompt": "在最忙的时候,你想怎样让她知道自己没有被忘记?", + "shortDialogue": "", + "hotspot": { + "x": 27, + "y": 0, + "w": 43, + "h": 100 + }, + "audioCueIds": [ + "S01-C08-MS006", + "S01-C08-MS007", + "S01-C08-TE900" + ], + "tableEcho": "热闹照得到客人,也该照得到端菜的人。", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg", + "caption": "热闹照得到客人,也该照得到端菜的人。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MS012", + "S01-C08-MS013", + "S01-C08-TE900", + "S01-C08-MS014" + ], + "tableEcho": "热闹照得到客人,也该照得到端菜的人。", + "cliffhanger": "现金盒合上前,铜牌背面的暗红漆与两张破损饭票一闪而过;下一场订席电话又响了。" + } + ] + } +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-08/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-08/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-08/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-08/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-08/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-08/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapter.js b/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapter.json b/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapter.wxml b/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapter.wxss b/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapterLayout.js b/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapterPages.js b/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-08/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-08/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-chapter-08/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-08/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-08/utils/comicPageModel.js b/TongjiUniApp/native/tang-detective/package-chapter-08/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-08/utils/comicPageModel.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-08/utils/comicReaderState.js b/TongjiUniApp/native/tang-detective/package-chapter-08/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-08/utils/comicReaderState.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-09/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-chapter-09/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-09/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-09/data/playableVisualPolicy.js b/TongjiUniApp/native/tang-detective/package-chapter-09/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-09/data/playableVisualPolicy.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-09/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/package-chapter-09/data/productionComicPages.js new file mode 100644 index 0000000..f28f998 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-09/data/productionComicPages.js @@ -0,0 +1,221 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C09": { + "pages": [ + { + "pageId": "S01-C09-P01", + "type": "chapter-title-and-opening-memory", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P01-opening-memory-v1.jpg", + "caption": "医院门刚合上,第一次聚餐,一盘菜从赵伯面前悄悄挪远。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "programDetailInset": { + "label": "桌边近景", + "x": 69, + "y": 51, + "w": 31, + "h": 35, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C09-MT000", + "S01-C09-MOM001", + "S01-C09-MOM002", + "S01-C09-MOM003", + "S01-C09-MTRANS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P02-opening-ensemble-v1.jpg", + "caption": "第一圈酒绕来,赵伯坐在原位看着;老周抬起手,唐守安摸向空杯,明远的酒壶停在桌中。", + "secondaryCaption": "", + "interactionPrompt": "先看看:谁在说自己的选择,谁还在替别人决定。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C09-MTRANS001", + "S01-C09-MS004", + "S01-C09-MS006", + "S01-C09-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P03", + "type": "event", + "eventId": "S01-H33", + "emotionMomentId": "", + "assetName": "S01-C09-P03-H33-driver-no-alcohol-v1.jpg", + "caption": "老周把车钥匙压在桌上,白酒仍被推来;他用整只手挡住杯子,一口也不碰。", + "secondaryCaption": "", + "interactionPrompt": "对要开车的人说“就一小口没事”,这样妥当吗?", + "shortDialogue": "老周按住钥匙:“车是我开,一口也不碰。”林秀兰把白水换到他面前。", + "hotspot": { + "x": 20, + "y": 0, + "w": 65, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS002", + "S01-C09-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P04", + "type": "event", + "eventId": "S01-H34", + "emotionMomentId": "", + "assetName": "S01-C09-P04-H34-tea-toast-v1.jpg", + "caption": "唐守安坐在靠门末席,轻盖空酒杯,端起茶;桌上的笑声一停,没人再把酒推回来。", + "secondaryCaption": "", + "interactionPrompt": "清楚拒绝饮酒,其他人也不继续起哄,这样更稳妥吗?", + "shortDialogue": "唐守安说:“心意我领,酒不喝。咱们拿茶照样碰。”", + "hotspot": { + "x": 21, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS006", + "S01-C09-MS007", + "S01-C09-MS009", + "S01-C09-MS013" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P05", + "type": "event", + "eventId": "S01-H35", + "emotionMomentId": "", + "assetName": "S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg", + "caption": "赵伯把自己的闭合随身药盒推向一旁,想为这场酒局把原来的安排往后挪。唐守安先请他停一下。", + "secondaryCaption": "涉及用药调整,应按专业人员确认的个人方案处理;不要自行停药或改量", + "outsideActionLabel": "打开赵伯这一刻", + "artTapHint": "画中赵伯也能点", + "interactionPrompt": "为了参加酒局,自行停药、补服、加倍、减量或调整胰岛素,这样妥当吗?", + "shortDialogue": "赵伯说:“今天喝酒,药先挪一挪?”唐守安摇头:“别在饭桌上自己改,先按你的方案办。”", + "hotspot": { + "x": 18, + "y": 0, + "w": 68, + "h": 99 + }, + "programDetailInset": { + "label": "手边近景", + "x": 8, + "y": 42, + "w": 50, + "h": 56, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C09-MS010", + "S01-C09-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P06", + "type": "event", + "eventId": "S01-H36", + "emotionMomentId": "", + "assetName": "S01-C09-P06-H36-stop-and-stay-v1.jpg", + "caption": "第三圈还没走完,赵伯出汗、手抖、回答变慢;唐守安先停酒、移开杯子,只留人陪在近处。", + "secondaryCaption": "", + "interactionPrompt": "发现出汗、手抖、反应变慢后,先停酒、移开危险物并留下人陪伴,这样更稳妥吗?", + "shortDialogue": "唐守安说:“先停酒,别让他一个人。能不能安全吞咽先看清,严重时马上联系急救。”", + "hotspot": { + "x": 22, + "y": 0, + "w": 62, + "h": 99 + }, + "programDetailInset": { + "label": "手边近景", + "x": 35, + "y": 42, + "w": 36, + "h": 39, + "anchor": "top-right" + }, + "audioCueIds": [ + "S01-C09-MS014", + "S01-C09-MS015", + "S01-C09-MS016" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM09", + "assetName": "S01-C09-P07-EM09-keep-my-seat-v1.jpg", + "caption": "酒杯停下后,赵伯仍坐在老位置。把座位留着,等他自己说愿不愿意坐下。", + "secondaryCaption": "", + "interactionPrompt": "你愿意怎样让赵伯先说出自己的担心?", + "shortDialogue": "", + "hotspot": { + "x": 24, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS004", + "S01-C09-MS005", + "S01-C09-MS007", + "S01-C09-MS008", + "S01-C09-MS009", + "S01-C09-TE900" + ], + "tableEcho": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg", + "caption": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C09-MS017", + "S01-C09-MS018", + "S01-C09-TE900", + "S01-C09-MS019" + ], + "tableEcho": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "memoryDisplayLines": [ + "陪他把选择说出来。", + "今天:开车就换茶或白水。", + "回家聊:先听赵伯把担心说完。" + ], + "cliffhanger": "赵伯推远酒盅,人和饭仍留在桌边;门外开始敲隔断。" + } + ] + } +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-09/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-09/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-09/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-09/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-09/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-09/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapter.js b/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapter.json b/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapter.wxml b/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapter.wxss b/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapterLayout.js b/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapterPages.js b/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-09/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-09/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-chapter-09/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-09/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-09/utils/comicPageModel.js b/TongjiUniApp/native/tang-detective/package-chapter-09/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-09/utils/comicPageModel.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-09/utils/comicReaderState.js b/TongjiUniApp/native/tang-detective/package-chapter-09/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-09/utils/comicReaderState.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-10/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-chapter-10/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-10/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-10/data/playableVisualPolicy.js b/TongjiUniApp/native/tang-detective/package-chapter-10/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-10/data/playableVisualPolicy.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-10/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/package-chapter-10/data/productionComicPages.js new file mode 100644 index 0000000..10ce1ff --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-10/data/productionComicPages.js @@ -0,0 +1,197 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C10": { + "pages": [ + { + "pageId": "S01-C10-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P01-one-palm-space-v1.jpg", + "caption": "旧桌留在后厨,女炊事员端碗回来,桌上只剩一掌空。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-MT000", + "S01-C10-MS001", + "S01-C10-MS002" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P02-opening-ensemble-v1.jpg", + "caption": "端碗的、添饭的、接电话的、端饭盒的,全站在一张不能坐的桌边。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁想坐下吃一顿完整的饭,谁又被别的事情拉走。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-MS003", + "S01-C10-MS005", + "S01-C10-MS006", + "S01-C10-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P03", + "type": "event", + "eventId": "S01-H37", + "emotionMomentId": "", + "assetName": "S01-C10-P03-H37-standing-meal-v1.jpg", + "caption": "女炊事员趁出菜空隙站着扒饭,脚边没有凳子,桌上也找不到落碗处。", + "secondaryCaption": "", + "interactionPrompt": "每天都在出菜间隙快速站着吃完,这样妥当吗?", + "shortDialogue": "女炊事员笑说:“站着吃快。”明远看着空不了的桌:“快,不等于每天都该这样。”", + "hotspot": { + "x": 21, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS001", + "S01-C10-MS002", + "S01-C10-MS003", + "S01-C10-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P04", + "type": "event", + "eventId": "S01-H38", + "emotionMomentId": "", + "assetName": "S01-C10-P04-H38-open-palm-v3.jpg", + "caption": "赵伯端着大碗、握着添饭勺;明远伸出手掌,清楚说自己已经饱了。", + "secondaryCaption": "", + "outsideActionLabel": "打开赵伯这一刻", + "artTapHint": "画中赵伯也能点", + "interactionPrompt": "用碗大、吃得多证明年轻男性有本事,这样妥当吗?", + "shortDialogue": "赵伯举起大碗:“男人吃饭,碗小了哪顶事?”明远摊开手掌:“赵叔,我已经饱了。”", + "hotspot": { + "x": 17, + "y": 0, + "w": 69, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS008", + "S01-C10-MS009", + "S01-C10-MS010", + "S01-C10-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P05", + "type": "event", + "eventId": "S01-H39", + "emotionMomentId": "", + "assetName": "S01-C10-P05-H39-phone-and-cold-meal-v1.jpg", + "caption": "长卷线座机又响,明远半坐半起去接;冷饭不再冒热气,墙钟早过了饭点。", + "secondaryCaption": "", + "interactionPrompt": "连续久坐接订席,正餐一拖再拖,这样妥当吗?", + "shortDialogue": "明远说:“接完这一个就吃。”电话那头又来一桌,他的筷子再次放下。", + "hotspot": { + "x": 18, + "y": 0, + "w": 70, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS003", + "S01-C10-MS004", + "S01-C10-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P06", + "type": "event", + "eventId": "S01-H40", + "emotionMomentId": "", + "assetName": "S01-C10-P06-H40-table-not-usable-v1.jpg", + "caption": "秦师傅说桌一直给自己人留着,可菜筐、酒箱和账本压满桌面,他的饭盒也无处放。", + "secondaryCaption": "", + "interactionPrompt": "口头说“给自己人留桌”,实际长期堆物不能坐,这样妥当吗?", + "shortDialogue": "秦师傅说:“这桌一直留着呢。”女炊事员指指酒箱:“留给谁了,酒瓶?”", + "hotspot": { + "x": 20, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS006", + "S01-C10-MS007", + "S01-C10-MS014" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM10", + "assetName": "S01-C10-P07-EM10-put-meal-down-v1.jpg", + "caption": "座机又响,明远一手拿听筒,一手端冷饭盒,桌上没有落碗处。", + "secondaryCaption": "", + "interactionPrompt": "你想怎样帮他把这一顿饭重新放回生活里?", + "shortDialogue": "", + "hotspot": { + "x": 20, + "y": 0, + "w": 66, + "h": 99 + }, + "programDetailInset": { + "label": "饭盒近景", + "x": 44, + "y": 45, + "w": 33, + "h": 37, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C10-MS005", + "S01-C10-MS012", + "S01-C10-MS013", + "S01-C10-TE900" + ], + "tableEcho": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P08-plan-line-last-stool-v1.jpg", + "caption": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-TE900", + "S01-C10-MS014" + ], + "tableEcho": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "cliffhanger": "规划红线压过桌脚,最后一张凳子被推走。字幕写着:“五年后,这张图再次展开。”" + } + ] + } +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-10/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-10/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-10/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-10/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-10/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-10/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapter.js b/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapter.json b/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapter.wxml b/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapter.wxss b/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapterLayout.js b/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapterPages.js b/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-10/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-10/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-chapter-10/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-10/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-10/utils/comicPageModel.js b/TongjiUniApp/native/tang-detective/package-chapter-10/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-10/utils/comicPageModel.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-10/utils/comicReaderState.js b/TongjiUniApp/native/tang-detective/package-chapter-10/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-10/utils/comicReaderState.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-11/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-chapter-11/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-11/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-11/data/playableVisualPolicy.js b/TongjiUniApp/native/tang-detective/package-chapter-11/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-11/data/playableVisualPolicy.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-11/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/package-chapter-11/data/productionComicPages.js new file mode 100644 index 0000000..c4976b7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-11/data/productionComicPages.js @@ -0,0 +1,184 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C11": { + "pages": [ + { + "pageId": "S01-C11-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P01-pen-above-paper-v1.jpg", + "caption": "旧图再次展开,秦师傅握着笔,先看旧桌,再看等他签字的人。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MT000", + "S01-C11-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P02-opening-ensemble-v1.jpg", + "caption": "小满擦展柜,秦师傅悬笔,施工负责人分通道;林秀兰与唐守安都在等他的决定。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在保存,谁在检查,谁在让路,谁又握着最后要落的笔。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MS002", + "S01-C11-MS003", + "S01-C11-MS004", + "S01-C11-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P03", + "type": "event", + "eventId": "S01-H41", + "emotionMomentId": "", + "assetName": "S01-C11-P03-H41-display-and-seat-v1.jpg", + "caption": "小满把玻璃与展板擦亮,回头才看见旧长凳正被搬走,林秀兰还在等她看那块空位。", + "secondaryCaption": "", + "interactionPrompt": "只把“健康、互助”做成展板,实际服务没有变化,这样妥当吗?", + "shortDialogue": "小满说:“这些字要留下。”林秀兰回她:“字留下了,人坐哪儿,也得留下。”", + "hotspot": { + "x": 20, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS002", + "S01-C11-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P04", + "type": "event", + "eventId": "S01-H42", + "emotionMomentId": "", + "assetName": "S01-C11-P04-H42-inspect-old-table-v1.jpg", + "caption": "秦师傅摸着旧桌沿想直接搬走,卷尺和检查表已递到手边,松动桌腿还没有人作结论。", + "secondaryCaption": "", + "interactionPrompt": "不检查结构安全,因怀旧就直接继续给客人使用,这样妥当吗?", + "shortDialogue": "秦师傅摸着桌沿:“它撑了几十年。”施工负责人说:“先查清还能不能安全撑今天。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS001", + "S01-C11-MS006", + "S01-C11-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P05", + "type": "event", + "eventId": "S01-H43", + "emotionMomentId": "", + "assetName": "S01-C11-P05-H43-clear-passage-v1.jpg", + "caption": "施工负责人亲手合上材料区围挡,又把通道口让开;唐守安和员工抬着长凳顺利通过。", + "secondaryCaption": "", + "interactionPrompt": "施工材料与人员必经路线分开,并保留清楚通道,这样更稳妥吗?", + "shortDialogue": "施工负责人说:“人走这一边,材料走那一边。看得见的通道,才真能走。”", + "hotspot": { + "x": 18, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS004", + "S01-C11-MS005", + "S01-C11-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P06", + "type": "event", + "eventId": "S01-H44", + "emotionMomentId": "", + "assetName": "S01-C11-P06-H44-pen-before-signature-v1.jpg", + "caption": "秦师傅把旧物清单又看一遍,笔尖终于落下半寸;林秀兰站在侧后,没有替他拿笔。", + "secondaryCaption": "", + "interactionPrompt": "白天签下旧物处置后,夜里仍保存完整桌板、铜牌与钥匙记录,这样更稳妥吗?", + "shortDialogue": "林秀兰问:“你不是说这桌不动吗?”秦师傅说:“饭店得活下来,人才能回来。”", + "hotspot": { + "x": 20, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS006", + "S01-C11-MS007", + "S01-C11-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM11", + "assetName": "S01-C11-P07-EM11-wrap-and-record-v1.jpg", + "caption": "夜里,秦师傅把桌板、铜牌和两枚破票逐件包好;空白记录还等他留下些什么。", + "secondaryCaption": "", + "interactionPrompt": "除了保存旧物,你想请秦师傅再留下些什么?", + "shortDialogue": "", + "hotspot": { + "x": 21, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS009", + "S01-C11-MS010", + "S01-C11-MS011", + "S01-C11-TE900" + ], + "tableEcho": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P08-can-we-open-now-v1.jpg", + "caption": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MS012", + "S01-C11-MS013", + "S01-C11-MS014" + ], + "tableEcho": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "cliffhanger": "十八年后,小满认出抽屉里的旧饭票夹。她没有擅自拿,只第三次问:“现在能开吗?”" + } + ] + } +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-11/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-11/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-11/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-11/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-11/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-11/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapter.js b/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapter.json b/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapter.wxml b/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapter.wxss b/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapterLayout.js b/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapterPages.js b/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-11/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-11/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-chapter-11/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-11/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-11/utils/comicPageModel.js b/TongjiUniApp/native/tang-detective/package-chapter-11/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-11/utils/comicPageModel.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-11/utils/comicReaderState.js b/TongjiUniApp/native/tang-detective/package-chapter-11/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-11/utils/comicReaderState.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-12/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-chapter-12/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-12/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-12/data/playableVisualPolicy.js b/TongjiUniApp/native/tang-detective/package-chapter-12/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-12/data/playableVisualPolicy.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-12/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/package-chapter-12/data/productionComicPages.js new file mode 100644 index 0000000..a13c93e --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-12/data/productionComicPages.js @@ -0,0 +1,198 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C12": { + "pages": [ + { + "pageId": "S01-C12-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P01-key-pauses-half-turn-v1.jpg", + "caption": "爷爷点了头,小满才取下饭票夹;钥匙转到一半,她又停下来说明。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MT000", + "S01-C12-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P02-modern-table-ensemble-v1.jpg", + "caption": "旧桌板被人擦亮,纸单被人铺开;赵伯按加号,明远拿起信息卡,秦师傅仍站在后厨门里。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在核对旧物,谁把点餐方式摆出来,谁又想替一家人一次安排完。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MS002", + "S01-C12-MS003", + "S01-C12-MS005", + "S01-C12-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P03", + "type": "event", + "eventId": "S01-H45", + "emotionMomentId": "", + "assetName": "S01-C12-P03-H45-provenance-chain-v1.jpg", + "caption": "小满先拍桌板全貌,再拍红漆、孔位和票据;林秀兰擦板,秦师傅把纸筒递到手边。", + "secondaryCaption": "", + "interactionPrompt": "征得秦师傅同意后开柜,并用红漆、孔位、板字、照片和票据共同核对,这样更稳妥吗?", + "shortDialogue": "小满说:“这回能开吗?”秦师傅点头。她又说:“一件一件记,别让一个孔替所有证据说话。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS001", + "S01-C12-MS002", + "S01-C12-MS003", + "S01-C12-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P04", + "type": "event", + "eventId": "S01-H46", + "emotionMomentId": "", + "assetName": "S01-C12-P04-H46-real-choice-counter-v1.jpg", + "caption": "小满把大字纸单推向老人,员工当面等他开口;扫码牌在旁,现金盒也正常打开。", + "secondaryCaption": "", + "interactionPrompt": "在扫码之外保留大字纸单、人工点餐,并保持正常现金收款,这样更稳妥吗?", + "shortDialogue": "小满说:“会扫码就扫,想看纸单就看纸单;有人在这儿帮,现金也照常收。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS005", + "S01-C12-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P05", + "type": "event", + "eventId": "S01-H47", + "emotionMomentId": "", + "assetName": "S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg", + "caption": "十个人围着真实饭桌,赵伯熟练按下加号;乐乐只按住自己的手,先问每份多大。", + "secondaryCaption": "", + "interactionPrompt": "因“七十周年不能空”继续加菜,这样妥当吗?", + "shortDialogue": "赵伯说:“十四不好听,再来俩。”乐乐按住减号:“十个人十四道,已经不是数学问题了。”", + "hotspot": { + "x": 15, + "y": 0, + "w": 69, + "h": 99 + }, + "programEvidenceInset": { + "kind": "order-count", + "kicker": "画中近景", + "screenLabel": "手机点单", + "countLabel": "已点", + "countValue": 14, + "countUnit": "道", + "plusGlyph": "+", + "note": "赵伯还在加菜", + "anchor": "top-right", + "ariaLabel": "画中近景:手机显示已点十四道,赵伯还要按加号继续加菜。" + }, + "audioCueIds": [ + "S01-C12-MS007", + "S01-C12-MS008", + "S01-C12-MS009" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P06", + "type": "event", + "eventId": "S01-H48", + "emotionMomentId": "", + "assetName": "S01-C12-P06-H48-card-is-not-a-promise-v1.jpg", + "caption": "明远把信息卡往父亲面前一放,像找到了省心答案;乐乐却指着空白栏,等他把话听完。", + "secondaryCaption": "", + "interactionPrompt": "看到“烹调方式:炖;本份约供2—3人;可选小份”,就理解成“健康保证”,这样妥当吗?", + "shortDialogue": "明远说:“写得这么健康,多点一份没事。”乐乐问:“它说怎么做、多大份,又没说能治病吧?”", + "hotspot": { + "x": 18, + "y": 0, + "w": 67, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS010", + "S01-C12-MS011", + "S01-C12-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM12", + "assetName": "S01-C12-P07-EM12-hand-choice-back-v1.jpg", + "caption": "手机、语音、纸单和服务员都在;乐乐把纸单放到赵伯手边,明远终于停下来等他开口。", + "secondaryCaption": "", + "interactionPrompt": "信息都在了,最后一步怎样交还给赵伯?", + "shortDialogue": "", + "hotspot": { + "x": 18, + "y": 0, + "w": 67, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS005", + "S01-C12-MS006", + "S01-C12-TE900" + ], + "tableEcho": "让人看懂、让人开口,才算把选择递到手里。", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P08-rolled-speech-cliffhanger-v1.jpg", + "caption": "让人看懂、让人开口,才算把选择递到手里。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MS013", + "S01-C12-MS014-ATTR", + "S01-C12-MS014", + "S01-C12-MS015", + "S01-C12-MS016" + ], + "tableEcho": "让人看懂、让人开口,才算把选择递到手里。", + "cliffhanger": "秦师傅把稿子重新卷起,只说:“先上菜。”林秀兰看见末行写着“请晚班的人原谅我”。" + } + ] + } +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-12/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-12/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-12/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-12/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-12/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-12/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapter.js b/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapter.json b/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapter.wxml b/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapter.wxss b/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapterLayout.js b/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapterPages.js b/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-12/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-12/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-chapter-12/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-12/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-12/utils/comicPageModel.js b/TongjiUniApp/native/tang-detective/package-chapter-12/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-12/utils/comicPageModel.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-12/utils/comicReaderState.js b/TongjiUniApp/native/tang-detective/package-chapter-12/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-12/utils/comicReaderState.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-13/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-chapter-13/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-13/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-13/data/playableVisualPolicy.js b/TongjiUniApp/native/tang-detective/package-chapter-13/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-13/data/playableVisualPolicy.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-13/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/package-chapter-13/data/productionComicPages.js new file mode 100644 index 0000000..27b0385 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-13/data/productionComicPages.js @@ -0,0 +1,196 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C13": { + "pages": [ + { + "pageId": "S01-C13-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P01-three-chopsticks-collide-v1.jpg", + "caption": "乐乐刚说饱,三双公筷却同时伸来;只听“叮”的一声,桌边静了半拍。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MT000", + "S01-C13-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P02-opening-ensemble-v1.jpg", + "caption": "乐乐护碗,明远推盘,赵伯的姓名牌去了侧桌;小满手里的软尺,还没有展开。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁还在替人夹,谁把自己的菜推过去,谁又让姓名牌先离了席。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MS002", + "S01-C13-MS003", + "S01-C13-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P03", + "type": "event", + "eventId": "S01-H49", + "emotionMomentId": "", + "assetName": "S01-C13-P03-H49-ask-before-serving-v1.jpg", + "caption": "三位长辈都想照顾孩子,却没有一个先问;公筷干净,也不能替乐乐说“要”。", + "secondaryCaption": "", + "interactionPrompt": "不问乐乐就轮流替他夹菜,这样妥当吗?", + "shortDialogue": "赵伯笑:“还排上号了。”乐乐护住碗:“公筷也是筷子,先问我呀!”", + "hotspot": { + "x": 14, + "y": 3, + "w": 70, + "h": 94 + }, + "audioCueIds": [ + "S01-C13-MS001", + "S01-C13-MS002", + "S01-C13-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P04", + "type": "event", + "eventId": "S01-H50", + "emotionMomentId": "", + "assetName": "S01-C13-P04-H50-name-card-leaves-first-v1.jpg", + "caption": "小满想把照顾安排周全,却先把姓名牌移去侧桌;赵伯本人仍坐在大家中间。", + "secondaryCaption": "", + "interactionPrompt": "因担心赵伯,未经商量就把他安排到隔开的桌上,这样妥当吗?", + "shortDialogue": "赵伯探身说:“我人还在这儿,牌先被请走了?”小满的手停在半空。", + "hotspot": { + "x": 43, + "y": 1, + "w": 54, + "h": 98 + }, + "programDetailInset": { + "label": "桌边近景", + "x": 65, + "y": 36, + "w": 35, + "h": 39, + "anchor": "top-right", + "labelAnchor": "bottom-left" + }, + "audioCueIds": [ + "S01-C13-MS006", + "S01-C13-MS007", + "S01-C13-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P05", + "type": "event", + "eventId": "S01-H51", + "emotionMomentId": "", + "assetName": "S01-C13-P05-H51-family-double-standard-v1.jpg", + "caption": "明远把自己的甜饮举到嘴边,另一只手还搭在推给乐乐的菜盘上;乐乐抬眼看他。", + "secondaryCaption": "", + "interactionPrompt": "大人提醒孩子少喝,自己却仍举着甜饮,这样妥当吗?", + "shortDialogue": "明远说:“饮料少喝。”乐乐看着他的瓶子:“爸爸,那咱们一起少喝,好吗?”", + "hotspot": { + "x": 18, + "y": 0, + "w": 68, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS004", + "S01-C13-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P06", + "type": "event", + "eventId": "S01-H52", + "emotionMomentId": "", + "assetName": "S01-C13-P06-H52-tape-stays-rolled-v1.jpg", + "caption": "小满看着姓名牌和卷起的软尺,没有先量侧桌;这一回,她决定先问坐桌的人。", + "secondaryCaption": "", + "interactionPrompt": "不先挪人,先问赵伯想坐哪里,再调整共同桌,这样更稳妥吗?", + "shortDialogue": "小满问:“赵伯,您想坐哪儿?”赵伯把椅子往里收了收:“我还坐大家旁边。”", + "hotspot": { + "x": 24, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS009", + "S01-C13-MS010", + "S01-C13-MS011", + "S01-C13-MS012", + "S01-C13-MS013", + "S01-C13-MS014", + "S01-C13-MS015" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM13", + "assetName": "S01-C13-P07-EM13-listen-to-child-v1.jpg", + "caption": "三双公筷终于都停下。乐乐还护着碗,等大人第一次不替他说话。", + "secondaryCaption": "", + "interactionPrompt": "这一回,家里人怎样让乐乐自己选?", + "shortDialogue": "", + "hotspot": { + "x": 24, + "y": 0, + "w": 54, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS010", + "S01-C13-MS011", + "S01-C13-MS012", + "S01-C13-TE900" + ], + "tableEcho": "这一次,大人终于听孩子把话说完。", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P08", + "type": "memory-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P08-cook-crosses-threshold-v1.jpg", + "caption": "为你好之前,先听一句“我要不要”;照顾也要把选择还给人。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MS016", + "S01-C13-MS017", + "S01-C13-MS018" + ], + "tableEcho": "", + "cliffhanger": "秦师傅端起一锅白菜热汤面,终于跨过躲了整晚的后厨门槛。" + } + ] + } +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-13/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-13/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-13/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-13/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-13/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-13/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapter.js b/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapter.json b/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapter.wxml b/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapter.wxss b/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapterLayout.js b/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapterPages.js b/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-13/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-13/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-chapter-13/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-13/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-13/utils/comicPageModel.js b/TongjiUniApp/native/tang-detective/package-chapter-13/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-13/utils/comicPageModel.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-13/utils/comicReaderState.js b/TongjiUniApp/native/tang-detective/package-chapter-13/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-13/utils/comicReaderState.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-14/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-chapter-14/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-14/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-14/data/playableVisualPolicy.js b/TongjiUniApp/native/tang-detective/package-chapter-14/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-14/data/playableVisualPolicy.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-14/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/package-chapter-14/data/productionComicPages.js new file mode 100644 index 0000000..2b4dbe8 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-14/data/productionComicPages.js @@ -0,0 +1,187 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C14": { + "pages": [ + { + "pageId": "S01-C14-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P01-lid-opens-old-scent-v1.jpg", + "caption": "锅盖一揭,白菜热汤面冒起白汽;赵伯一句“就这”,忽然停在了半截。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MT000", + "S01-C14-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P02-responsibility-ensemble-v1.jpg", + "caption": "秦师傅扶住旧板,林秀兰排开证据;唐守安停在门框,赵伯从普通座端茶起身。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在说自己的责任,谁只补证据,谁没有往主位走,谁仍在桌上举杯。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MS002", + "S01-C14-MS003", + "S01-C14-MS004", + "S01-C14-MS005", + "S01-C14-MS006", + "S01-C14-MS007-ATTR", + "S01-C14-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P03", + "type": "event", + "eventId": "S01-H53", + "emotionMomentId": "", + "assetName": "S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg", + "caption": "秦师傅只讲白菜、面、热汤、做法与份量;旧味能留人情,不能替代治疗。", + "secondaryCaption": "", + "interactionPrompt": "因为是老菜,就宣传成“祖传降糖面”,这样妥当吗?", + "shortDialogue": "秦师傅说:“就是白菜、面和一锅热汤。讲做法、讲份量,别给它封神。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 68, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS001", + "S01-C14-MS002", + "S01-C14-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P04", + "type": "event", + "eventId": "S01-H54", + "emotionMomentId": "", + "assetName": "S01-C14-P04-H54-evidence-chain-v1.jpg", + "caption": "林秀兰把两枚破票、铜牌、红漆、孔位、板字与照片逐项核对;没有一件物证独自定案。", + "secondaryCaption": "", + "interactionPrompt": "把票据、铜牌、暗红漆、孔位、板字和照片一起核对,这样更稳妥吗?", + "shortDialogue": "林秀兰说:“每件东西只说自己知道的那一段,合起来,故事才站得稳。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 71, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS004", + "S01-C14-MS007-ATTR", + "S01-C14-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P05", + "type": "event", + "eventId": "S01-H55", + "emotionMomentId": "", + "assetName": "S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg", + "caption": "唐守安停在门框,没有去主位;赵伯拉开身边的普通座,秦师傅的话仍由秦师傅说完。", + "secondaryCaption": "", + "interactionPrompt": "大家请他坐主位,他选择普通座并让秦师傅自己说完,这样更稳妥吗?", + "shortDialogue": "赵伯喊:“给唐大夫留个座。”唐守安摆手:“普通座就好,先听老秦把话说完。”", + "hotspot": { + "x": 48, + "y": 1, + "w": 48, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS008", + "S01-C14-MS009", + "S01-C14-MS010" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P06", + "type": "event", + "eventId": "S01-H56", + "emotionMomentId": "", + "assetName": "S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg", + "caption": "赵伯把蓝花盖碗转半圈,主动同大家碰杯;不用酒证明能耐,他也没有离开这张桌。", + "secondaryCaption": "", + "interactionPrompt": "用茶参加碰杯,也不再要求别人喝酒,这样更稳妥吗?", + "shortDialogue": "赵伯说:“这回我当个不劝酒、不硬撑的骨干。老伙计,茶也算一杯。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 65, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS011", + "S01-C14-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM14", + "assetName": "S01-C14-P07-EM14-watch-face-down-v1.jpg", + "caption": "父子同高坐下,那只曾让问话匆匆结束的手表,还亮在两人之间。", + "secondaryCaption": "", + "interactionPrompt": "唐守安怎样让儿子把那句旧话真正说完?", + "shortDialogue": "", + "hotspot": { + "x": 8, + "y": 1, + "w": 84, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS012", + "S01-C14-MS013", + "S01-C14-MS014", + "S01-C14-MS015", + "S01-C14-TE900" + ], + "tableEcho": "会解决问题的人,也可以学着先听一会儿。", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P08", + "type": "memory-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P08-measure-the-empty-place-v1.jpg", + "caption": "会解决问题,也要先听一会儿;桌子能否回来,先看那块空位最终坐回谁。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MS016", + "S01-C14-MS017" + ], + "tableEcho": "", + "cliffhanger": "秦师傅问:“十五桌还能回来吗?”小满没有回答,先拿软尺重新量空位。" + } + ] + } +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-14/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-14/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-14/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-14/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-14/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-14/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapter.js b/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapter.json b/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapter.wxml b/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapter.wxss b/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapterLayout.js b/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapterPages.js b/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-14/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-14/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-chapter-14/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-14/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-14/utils/comicPageModel.js b/TongjiUniApp/native/tang-detective/package-chapter-14/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-14/utils/comicPageModel.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-14/utils/comicReaderState.js b/TongjiUniApp/native/tang-detective/package-chapter-14/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-14/utils/comicReaderState.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-15/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-chapter-15/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-15/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-15/data/playableVisualPolicy.js b/TongjiUniApp/native/tang-detective/package-chapter-15/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-15/data/playableVisualPolicy.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-15/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/package-chapter-15/data/productionComicPages.js new file mode 100644 index 0000000..a0ce914 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-15/data/productionComicPages.js @@ -0,0 +1,204 @@ +// Generated package-local production comic data. Do not hand-edit. +module.exports = { + "S01-C15": { + "pages": [ + { + "pageId": "S01-C15-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P01-chair-opens-for-late-workers-v1.jpg", + "caption": "三周后,晚市将收;小满先拉开椅子,门口那几个人终于不用再等。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C15-MT000", + "S01-C15-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P02-everyone-makes-room-ensemble-v1.jpg", + "caption": "小满把椅子朝共同桌拉开,赵伯抬手招呼;两位晚班工友走进门,来晚的人也有位置。", + "secondaryCaption": "", + "interactionPrompt": "看看小满把椅子拉向哪里,再看门口的人正往哪里走。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C15-MS002", + "S01-C15-MS003", + "S01-C15-MS004", + "S01-C15-MS005", + "S01-C15-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P03", + "type": "event", + "eventId": "S01-H57", + "emotionMomentId": "", + "assetName": "S01-C15-P03-H57-three-real-portions-v1.jpg", + "caption": "小甄先看来了几个人,再把普通份、小份、半份三种真实餐盘推到大家眼前。", + "secondaryCaption": "", + "interactionPrompt": "菜单提供多种份量并写建议用餐人数,这样更稳妥吗?", + "shortDialogue": "小甄说:“先看几个人、每份多大。少点不够再添,比猜一桌需要多少更踏实。”", + "hotspot": { + "x": 17, + "y": 1, + "w": 55, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS003", + "S01-C15-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P04", + "type": "event", + "eventId": "S01-H58", + "emotionMomentId": "", + "assetName": "S01-C15-P04-H58-water-within-reach-v1.jpg", + "caption": "小甄把白水壶放到共同桌边;其他饮品仍在侧柜,先问本人,再决定要不要。", + "secondaryCaption": "", + "interactionPrompt": "默认让白水容易取得,酒和甜饮由本人选择,不替所有人自动摆上,这样更稳妥吗?", + "shortDialogue": "小甄说:“白水先放手边,其他饮品看清信息、问过本人,再决定要不要。”", + "hotspot": { + "x": 41, + "y": 1, + "w": 49, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS003", + "S01-C15-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P05", + "type": "event", + "eventId": "S01-H59", + "emotionMomentId": "", + "assetName": "S01-C15-P05-H59-ask-before-serving-v1.jpg", + "caption": "明远的公筷停在乐乐碗外。他先看孩子的脸,再问:“这个还要吗?”", + "secondaryCaption": "", + "interactionPrompt": "夹菜前先问乐乐还要不要,这样更稳妥吗?", + "shortDialogue": "明远问:“这个还要吗?”乐乐点头:“要一点,我自己夹。”明远把公筷递过去。", + "hotspot": { + "x": 12, + "y": 1, + "w": 58, + "h": 98 + }, + "programDetailInset": { + "label": "公筷停住", + "x": 22, + "y": 38, + "w": 51, + "h": 57, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C15-MS007", + "S01-C15-MS008", + "S01-C15-MS009", + "S01-C15-MS010", + "S01-C15-MS011", + "S01-C15-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P06", + "type": "event", + "eventId": "S01-H60", + "emotionMomentId": "", + "assetName": "S01-C15-P06-H60-check-before-packing-v1.jpg", + "caption": "员工没有把剩菜一股脑装盒;她逐盘询问,也把保存和再加热提示一项项说明。", + "secondaryCaption": "", + "interactionPrompt": "所有剩菜不分情况都必须打包,这样妥当吗?", + "shortDialogue": "员工问:“这些要带走吗?我先把能不能保存、怎么再加热给您说清。”", + "hotspot": { + "x": 31, + "y": 1, + "w": 58, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS016" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM15", + "assetName": "S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg", + "caption": "同一机位里,人已坐满;老吕带来的右缺口蓝边旧碗,还是空的。", + "secondaryCaption": "", + "interactionPrompt": "这只旧碗怎样留在今天的第十五桌边?", + "shortDialogue": "", + "hotspot": { + "x": 12, + "y": 1, + "w": 76, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS013", + "S01-C15-MS014", + "S01-C15-MS015", + "S01-C15-MS016", + "S01-C15-TE900", + "S01-C15-MS017" + ], + "tableEcho": "桌子回来了,人也都坐回来了。", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P08", + "type": "memory-season-close", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P08-people-seated-season-close-v2.jpg", + "caption": "第一回少的桌,如今有人坐回来了。铜牌回到桌沿,晚班工友也坐回来了。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "programDetailInset": { + "label": "秦师傅", + "x": 72, + "y": 7, + "w": 25, + "h": 44, + "anchor": "top-right" + }, + "audioCueIds": [ + "S01-C15-MS016", + "S01-C15-TE900", + "S01-C15-MS017", + "S01-C15-MS018" + ], + "tableEcho": "", + "cliffhanger": "快门按下,旧铜牌“15”重新固定。原来缺的不是一张桌,是这些人应该有的位置。" + } + ] + } +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-15/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-15/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-15/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-15/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-chapter-15/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-15/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapter.js b/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapter.json b/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapter.wxml b/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapter.wxss b/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapterLayout.js b/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapterPages.js b/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-15/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-15/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-chapter-15/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-15/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-15/utils/comicPageModel.js b/TongjiUniApp/native/tang-detective/package-chapter-15/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-15/utils/comicPageModel.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-chapter-15/utils/comicReaderState.js b/TongjiUniApp/native/tang-detective/package-chapter-15/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-chapter-15/utils/comicReaderState.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-game/data/assetReleaseConfig.js b/TongjiUniApp/native/tang-detective/package-game/data/assetReleaseConfig.js new file mode 100644 index 0000000..8261c0b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-game/data/assetReleaseConfig.js @@ -0,0 +1,14 @@ +/** + * 发布环境可覆盖这些值。 + * + * cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接 + * 使用包内种子图或返回文字回退,不会发起网络请求。 + */ +module.exports = { + cdnBaseUrl: '', + imageCacheBudgetBytes: 28 * 1024 * 1024, + audioCacheBudgetBytes: 32 * 1024 * 1024, + audioCacheMaxEntries: 32, + prefetchAhead: 2, + downloadConcurrency: 2, +} diff --git a/TongjiUniApp/native/tang-detective/package-game/data/playableVisualPolicy.js b/TongjiUniApp/native/tang-detective/package-game/data/playableVisualPolicy.js new file mode 100644 index 0000000..9031163 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-game/data/playableVisualPolicy.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-game/data/productionComicPages.js b/TongjiUniApp/native/tang-detective/package-game/data/productionComicPages.js new file mode 100644 index 0000000..c191ba8 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-game/data/productionComicPages.js @@ -0,0 +1,2067 @@ +// Generated from the reviewed C04 and C06-C15 production storyboards. 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": "" + } + ] + }, + "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": "第一桌社会客人进门,指着靠门的第十五桌:“这张给我们拼个大桌。”" + } + ] + }, + "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": "三年后的电话问:“五月初八,十桌婚宴,敢不敢接?”秦师傅看向靠墙的第十五桌。" + } + ] + }, + "S01-C08": { + "pages": [ + { + "pageId": "S01-C08-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P01-title-v1.jpg", + "caption": "第一场婚宴热闹开席,第十五桌却在掌声里被推向后厨。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MT000", + "S01-C08-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P02-opening-ensemble-v1.jpg", + "caption": "主家怕桌上不够体面,秀兰翻看复写菜单;女炊事员端着饭盒,秦师傅正挪员工桌。", + "secondaryCaption": "", + "interactionPrompt": "先看看:谁在顾客桌的体面,谁正失去坐下吃饭的位置。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MS001", + "S01-C08-MS002", + "S01-C08-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P03", + "type": "event", + "eventId": "S01-H29", + "emotionMomentId": "", + "assetName": "S01-C08-P03-H29-extra-dishes-v1.jpg", + "caption": "菜还没上齐,转盘已经没有落筷子的空;主家仍怕显得小气,隔着满桌招手加菜。", + "secondaryCaption": "", + "interactionPrompt": "只因怕被说小气,再增加几道没人需要的菜,这样妥当吗?", + "shortDialogue": "主家说:“四凉八热才像样,再添两个硬菜。”林秀兰问:“先看看十个人吃到哪儿了?”", + "hotspot": { + "x": 27, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS002", + "S01-C08-MS003", + "S01-C08-MS004", + "S01-C08-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P04", + "type": "event", + "eventId": "S01-H30", + "emotionMomentId": "", + "assetName": "S01-C08-P04-H30-rejected-box-v1.jpg", + "caption": "宾客渐散,桌上还剩半桌;主家只因觉得没面子,把女炊事员递来的打包盒推回。", + "secondaryCaption": "", + "interactionPrompt": "只因觉得打包没面子,就拒绝带走适合安全保存的剩菜,这样妥当吗?", + "shortDialogue": "主家说:“喜事哪能拎剩菜走。”女炊事员答:“先问哪些能带,别让面子替食物做决定。”", + "hotspot": { + "x": 28, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P05", + "type": "event", + "eventId": "S01-H31", + "emotionMomentId": "", + "assetName": "S01-C08-P05-H31-menu-gaps-v1.jpg", + "caption": "秀兰把复写菜单翻过来,纸上只有菜名,没有人数,也没有一盘大约够几个人。", + "secondaryCaption": "", + "interactionPrompt": "套餐只列菜名,不说明大致份量和供几人,这样妥当吗?", + "shortDialogue": "林秀兰说:“光有菜名,不知道多大盘,客人怎么知道够不够?”", + "hotspot": { + "x": 22, + "y": 0, + "w": 61, + "h": 99 + }, + "audioCueIds": [ + "S01-C08-MS008", + "S01-C08-MS009", + "S01-C08-MS010" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P06", + "type": "event", + "eventId": "S01-H32", + "emotionMomentId": "", + "assetName": "S01-C08-P06-H32-removed-plaque-v1.jpg", + "caption": "客桌加了,员工的座位却没了。秦师傅说:“就借这一晚。”", + "secondaryCaption": "", + "interactionPrompt": "为临时加桌,让员工整晚端碗站着吃,并说“就借这一晚”,这样妥当吗?", + "shortDialogue": "秦师傅说:“头一场席,就借一晚。”林秀兰停了一拍:“我也说过,就这一场。”", + "hotspot": { + "x": 23, + "y": 5, + "w": 44, + "h": 90 + }, + "audioCueIds": [ + "S01-C08-MS006", + "S01-C08-MS007", + "S01-C08-MS012", + "S01-C08-MS013" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM08", + "assetName": "S01-C08-P07-EM08-back-kitchen-light-v1.jpg", + "caption": "婚宴笑声还没散,女炊事员端着饭盒站在传菜口,一时找不到放碗的位置。", + "secondaryCaption": "", + "interactionPrompt": "在最忙的时候,你想怎样让她知道自己没有被忘记?", + "shortDialogue": "", + "hotspot": { + "x": 27, + "y": 0, + "w": 43, + "h": 100 + }, + "audioCueIds": [ + "S01-C08-MS006", + "S01-C08-MS007", + "S01-C08-TE900" + ], + "tableEcho": "热闹照得到客人,也该照得到端菜的人。", + "cliffhanger": "" + }, + { + "pageId": "S01-C08-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg", + "caption": "热闹照得到客人,也该照得到端菜的人。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C08-MS012", + "S01-C08-MS013", + "S01-C08-TE900", + "S01-C08-MS014" + ], + "tableEcho": "热闹照得到客人,也该照得到端菜的人。", + "cliffhanger": "现金盒合上前,铜牌背面的暗红漆与两张破损饭票一闪而过;下一场订席电话又响了。" + } + ] + }, + "S01-C09": { + "pages": [ + { + "pageId": "S01-C09-P01", + "type": "chapter-title-and-opening-memory", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P01-opening-memory-v1.jpg", + "caption": "医院门刚合上,第一次聚餐,一盘菜从赵伯面前悄悄挪远。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "programDetailInset": { + "label": "桌边近景", + "x": 69, + "y": 51, + "w": 31, + "h": 35, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C09-MT000", + "S01-C09-MOM001", + "S01-C09-MOM002", + "S01-C09-MOM003", + "S01-C09-MTRANS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P02-opening-ensemble-v1.jpg", + "caption": "第一圈酒绕来,赵伯坐在原位看着;老周抬起手,唐守安摸向空杯,明远的酒壶停在桌中。", + "secondaryCaption": "", + "interactionPrompt": "先看看:谁在说自己的选择,谁还在替别人决定。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C09-MTRANS001", + "S01-C09-MS004", + "S01-C09-MS006", + "S01-C09-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P03", + "type": "event", + "eventId": "S01-H33", + "emotionMomentId": "", + "assetName": "S01-C09-P03-H33-driver-no-alcohol-v1.jpg", + "caption": "老周把车钥匙压在桌上,白酒仍被推来;他用整只手挡住杯子,一口也不碰。", + "secondaryCaption": "", + "interactionPrompt": "对要开车的人说“就一小口没事”,这样妥当吗?", + "shortDialogue": "老周按住钥匙:“车是我开,一口也不碰。”林秀兰把白水换到他面前。", + "hotspot": { + "x": 20, + "y": 0, + "w": 65, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS002", + "S01-C09-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P04", + "type": "event", + "eventId": "S01-H34", + "emotionMomentId": "", + "assetName": "S01-C09-P04-H34-tea-toast-v1.jpg", + "caption": "唐守安坐在靠门末席,轻盖空酒杯,端起茶;桌上的笑声一停,没人再把酒推回来。", + "secondaryCaption": "", + "interactionPrompt": "清楚拒绝饮酒,其他人也不继续起哄,这样更稳妥吗?", + "shortDialogue": "唐守安说:“心意我领,酒不喝。咱们拿茶照样碰。”", + "hotspot": { + "x": 21, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS006", + "S01-C09-MS007", + "S01-C09-MS009", + "S01-C09-MS013" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P05", + "type": "event", + "eventId": "S01-H35", + "emotionMomentId": "", + "assetName": "S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg", + "caption": "赵伯把自己的闭合随身药盒推向一旁,想为这场酒局把原来的安排往后挪。唐守安先请他停一下。", + "secondaryCaption": "涉及用药调整,应按专业人员确认的个人方案处理;不要自行停药或改量", + "outsideActionLabel": "打开赵伯这一刻", + "artTapHint": "画中赵伯也能点", + "interactionPrompt": "为了参加酒局,自行停药、补服、加倍、减量或调整胰岛素,这样妥当吗?", + "shortDialogue": "赵伯说:“今天喝酒,药先挪一挪?”唐守安摇头:“别在饭桌上自己改,先按你的方案办。”", + "hotspot": { + "x": 18, + "y": 0, + "w": 68, + "h": 99 + }, + "programDetailInset": { + "label": "手边近景", + "x": 8, + "y": 42, + "w": 50, + "h": 56, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C09-MS010", + "S01-C09-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P06", + "type": "event", + "eventId": "S01-H36", + "emotionMomentId": "", + "assetName": "S01-C09-P06-H36-stop-and-stay-v1.jpg", + "caption": "第三圈还没走完,赵伯出汗、手抖、回答变慢;唐守安先停酒、移开杯子,只留人陪在近处。", + "secondaryCaption": "", + "interactionPrompt": "发现出汗、手抖、反应变慢后,先停酒、移开危险物并留下人陪伴,这样更稳妥吗?", + "shortDialogue": "唐守安说:“先停酒,别让他一个人。能不能安全吞咽先看清,严重时马上联系急救。”", + "hotspot": { + "x": 22, + "y": 0, + "w": 62, + "h": 99 + }, + "programDetailInset": { + "label": "手边近景", + "x": 35, + "y": 42, + "w": 36, + "h": 39, + "anchor": "top-right" + }, + "audioCueIds": [ + "S01-C09-MS014", + "S01-C09-MS015", + "S01-C09-MS016" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM09", + "assetName": "S01-C09-P07-EM09-keep-my-seat-v1.jpg", + "caption": "酒杯停下后,赵伯仍坐在老位置。把座位留着,等他自己说愿不愿意坐下。", + "secondaryCaption": "", + "interactionPrompt": "你愿意怎样让赵伯先说出自己的担心?", + "shortDialogue": "", + "hotspot": { + "x": 24, + "y": 0, + "w": 58, + "h": 99 + }, + "audioCueIds": [ + "S01-C09-MS004", + "S01-C09-MS005", + "S01-C09-MS007", + "S01-C09-MS008", + "S01-C09-MS009", + "S01-C09-TE900" + ], + "tableEcho": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "cliffhanger": "" + }, + { + "pageId": "S01-C09-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg", + "caption": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C09-MS017", + "S01-C09-MS018", + "S01-C09-TE900", + "S01-C09-MS019" + ], + "tableEcho": "照顾不是把人摘出去,是陪他把自己的选择说出来。", + "memoryDisplayLines": [ + "陪他把选择说出来。", + "今天:开车就换茶或白水。", + "回家聊:先听赵伯把担心说完。" + ], + "cliffhanger": "赵伯推远酒盅,人和饭仍留在桌边;门外开始敲隔断。" + } + ] + }, + "S01-C10": { + "pages": [ + { + "pageId": "S01-C10-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P01-one-palm-space-v1.jpg", + "caption": "旧桌留在后厨,女炊事员端碗回来,桌上只剩一掌空。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-MT000", + "S01-C10-MS001", + "S01-C10-MS002" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P02-opening-ensemble-v1.jpg", + "caption": "端碗的、添饭的、接电话的、端饭盒的,全站在一张不能坐的桌边。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁想坐下吃一顿完整的饭,谁又被别的事情拉走。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-MS003", + "S01-C10-MS005", + "S01-C10-MS006", + "S01-C10-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P03", + "type": "event", + "eventId": "S01-H37", + "emotionMomentId": "", + "assetName": "S01-C10-P03-H37-standing-meal-v1.jpg", + "caption": "女炊事员趁出菜空隙站着扒饭,脚边没有凳子,桌上也找不到落碗处。", + "secondaryCaption": "", + "interactionPrompt": "每天都在出菜间隙快速站着吃完,这样妥当吗?", + "shortDialogue": "女炊事员笑说:“站着吃快。”明远看着空不了的桌:“快,不等于每天都该这样。”", + "hotspot": { + "x": 21, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS001", + "S01-C10-MS002", + "S01-C10-MS003", + "S01-C10-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P04", + "type": "event", + "eventId": "S01-H38", + "emotionMomentId": "", + "assetName": "S01-C10-P04-H38-open-palm-v3.jpg", + "caption": "赵伯端着大碗、握着添饭勺;明远伸出手掌,清楚说自己已经饱了。", + "secondaryCaption": "", + "outsideActionLabel": "打开赵伯这一刻", + "artTapHint": "画中赵伯也能点", + "interactionPrompt": "用碗大、吃得多证明年轻男性有本事,这样妥当吗?", + "shortDialogue": "赵伯举起大碗:“男人吃饭,碗小了哪顶事?”明远摊开手掌:“赵叔,我已经饱了。”", + "hotspot": { + "x": 17, + "y": 0, + "w": 69, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS008", + "S01-C10-MS009", + "S01-C10-MS010", + "S01-C10-MS011" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P05", + "type": "event", + "eventId": "S01-H39", + "emotionMomentId": "", + "assetName": "S01-C10-P05-H39-phone-and-cold-meal-v1.jpg", + "caption": "长卷线座机又响,明远半坐半起去接;冷饭不再冒热气,墙钟早过了饭点。", + "secondaryCaption": "", + "interactionPrompt": "连续久坐接订席,正餐一拖再拖,这样妥当吗?", + "shortDialogue": "明远说:“接完这一个就吃。”电话那头又来一桌,他的筷子再次放下。", + "hotspot": { + "x": 18, + "y": 0, + "w": 70, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS003", + "S01-C10-MS004", + "S01-C10-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P06", + "type": "event", + "eventId": "S01-H40", + "emotionMomentId": "", + "assetName": "S01-C10-P06-H40-table-not-usable-v1.jpg", + "caption": "秦师傅说桌一直给自己人留着,可菜筐、酒箱和账本压满桌面,他的饭盒也无处放。", + "secondaryCaption": "", + "interactionPrompt": "口头说“给自己人留桌”,实际长期堆物不能坐,这样妥当吗?", + "shortDialogue": "秦师傅说:“这桌一直留着呢。”女炊事员指指酒箱:“留给谁了,酒瓶?”", + "hotspot": { + "x": 20, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C10-MS006", + "S01-C10-MS007", + "S01-C10-MS014" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM10", + "assetName": "S01-C10-P07-EM10-put-meal-down-v1.jpg", + "caption": "座机又响,明远一手拿听筒,一手端冷饭盒,桌上没有落碗处。", + "secondaryCaption": "", + "interactionPrompt": "你想怎样帮他把这一顿饭重新放回生活里?", + "shortDialogue": "", + "hotspot": { + "x": 20, + "y": 0, + "w": 66, + "h": 99 + }, + "programDetailInset": { + "label": "饭盒近景", + "x": 44, + "y": 45, + "w": 33, + "h": 37, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C10-MS005", + "S01-C10-MS012", + "S01-C10-MS013", + "S01-C10-TE900" + ], + "tableEcho": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "cliffhanger": "" + }, + { + "pageId": "S01-C10-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C10-P08-plan-line-last-stool-v1.jpg", + "caption": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C10-TE900", + "S01-C10-MS014" + ], + "tableEcho": "桌上腾不出一只碗,忙碌就已经挤到人身上了。", + "cliffhanger": "规划红线压过桌脚,最后一张凳子被推走。字幕写着:“五年后,这张图再次展开。”" + } + ] + }, + "S01-C11": { + "pages": [ + { + "pageId": "S01-C11-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P01-pen-above-paper-v1.jpg", + "caption": "旧图再次展开,秦师傅握着笔,先看旧桌,再看等他签字的人。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MT000", + "S01-C11-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P02-opening-ensemble-v1.jpg", + "caption": "小满擦展柜,秦师傅悬笔,施工负责人分通道;林秀兰与唐守安都在等他的决定。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在保存,谁在检查,谁在让路,谁又握着最后要落的笔。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MS002", + "S01-C11-MS003", + "S01-C11-MS004", + "S01-C11-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P03", + "type": "event", + "eventId": "S01-H41", + "emotionMomentId": "", + "assetName": "S01-C11-P03-H41-display-and-seat-v1.jpg", + "caption": "小满把玻璃与展板擦亮,回头才看见旧长凳正被搬走,林秀兰还在等她看那块空位。", + "secondaryCaption": "", + "interactionPrompt": "只把“健康、互助”做成展板,实际服务没有变化,这样妥当吗?", + "shortDialogue": "小满说:“这些字要留下。”林秀兰回她:“字留下了,人坐哪儿,也得留下。”", + "hotspot": { + "x": 20, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS002", + "S01-C11-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P04", + "type": "event", + "eventId": "S01-H42", + "emotionMomentId": "", + "assetName": "S01-C11-P04-H42-inspect-old-table-v1.jpg", + "caption": "秦师傅摸着旧桌沿想直接搬走,卷尺和检查表已递到手边,松动桌腿还没有人作结论。", + "secondaryCaption": "", + "interactionPrompt": "不检查结构安全,因怀旧就直接继续给客人使用,这样妥当吗?", + "shortDialogue": "秦师傅摸着桌沿:“它撑了几十年。”施工负责人说:“先查清还能不能安全撑今天。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS001", + "S01-C11-MS006", + "S01-C11-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P05", + "type": "event", + "eventId": "S01-H43", + "emotionMomentId": "", + "assetName": "S01-C11-P05-H43-clear-passage-v1.jpg", + "caption": "施工负责人亲手合上材料区围挡,又把通道口让开;唐守安和员工抬着长凳顺利通过。", + "secondaryCaption": "", + "interactionPrompt": "施工材料与人员必经路线分开,并保留清楚通道,这样更稳妥吗?", + "shortDialogue": "施工负责人说:“人走这一边,材料走那一边。看得见的通道,才真能走。”", + "hotspot": { + "x": 18, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS004", + "S01-C11-MS005", + "S01-C11-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P06", + "type": "event", + "eventId": "S01-H44", + "emotionMomentId": "", + "assetName": "S01-C11-P06-H44-pen-before-signature-v1.jpg", + "caption": "秦师傅把旧物清单又看一遍,笔尖终于落下半寸;林秀兰站在侧后,没有替他拿笔。", + "secondaryCaption": "", + "interactionPrompt": "白天签下旧物处置后,夜里仍保存完整桌板、铜牌与钥匙记录,这样更稳妥吗?", + "shortDialogue": "林秀兰问:“你不是说这桌不动吗?”秦师傅说:“饭店得活下来,人才能回来。”", + "hotspot": { + "x": 20, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS006", + "S01-C11-MS007", + "S01-C11-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM11", + "assetName": "S01-C11-P07-EM11-wrap-and-record-v1.jpg", + "caption": "夜里,秦师傅把桌板、铜牌和两枚破票逐件包好;空白记录还等他留下些什么。", + "secondaryCaption": "", + "interactionPrompt": "除了保存旧物,你想请秦师傅再留下些什么?", + "shortDialogue": "", + "hotspot": { + "x": 21, + "y": 0, + "w": 64, + "h": 99 + }, + "audioCueIds": [ + "S01-C11-MS009", + "S01-C11-MS010", + "S01-C11-MS011", + "S01-C11-TE900" + ], + "tableEcho": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "cliffhanger": "" + }, + { + "pageId": "S01-C11-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C11-P08-can-we-open-now-v1.jpg", + "caption": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C11-MS012", + "S01-C11-MS013", + "S01-C11-MS014" + ], + "tableEcho": "东西收进柜里叫保存,规矩留在人间才叫传下去。", + "cliffhanger": "十八年后,小满认出抽屉里的旧饭票夹。她没有擅自拿,只第三次问:“现在能开吗?”" + } + ] + }, + "S01-C12": { + "pages": [ + { + "pageId": "S01-C12-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P01-key-pauses-half-turn-v1.jpg", + "caption": "爷爷点了头,小满才取下饭票夹;钥匙转到一半,她又停下来说明。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MT000", + "S01-C12-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P02-modern-table-ensemble-v1.jpg", + "caption": "旧桌板被人擦亮,纸单被人铺开;赵伯按加号,明远拿起信息卡,秦师傅仍站在后厨门里。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在核对旧物,谁把点餐方式摆出来,谁又想替一家人一次安排完。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MS002", + "S01-C12-MS003", + "S01-C12-MS005", + "S01-C12-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P03", + "type": "event", + "eventId": "S01-H45", + "emotionMomentId": "", + "assetName": "S01-C12-P03-H45-provenance-chain-v1.jpg", + "caption": "小满先拍桌板全貌,再拍红漆、孔位和票据;林秀兰擦板,秦师傅把纸筒递到手边。", + "secondaryCaption": "", + "interactionPrompt": "征得秦师傅同意后开柜,并用红漆、孔位、板字、照片和票据共同核对,这样更稳妥吗?", + "shortDialogue": "小满说:“这回能开吗?”秦师傅点头。她又说:“一件一件记,别让一个孔替所有证据说话。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS001", + "S01-C12-MS002", + "S01-C12-MS003", + "S01-C12-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P04", + "type": "event", + "eventId": "S01-H46", + "emotionMomentId": "", + "assetName": "S01-C12-P04-H46-real-choice-counter-v1.jpg", + "caption": "小满把大字纸单推向老人,员工当面等他开口;扫码牌在旁,现金盒也正常打开。", + "secondaryCaption": "", + "interactionPrompt": "在扫码之外保留大字纸单、人工点餐,并保持正常现金收款,这样更稳妥吗?", + "shortDialogue": "小满说:“会扫码就扫,想看纸单就看纸单;有人在这儿帮,现金也照常收。”", + "hotspot": { + "x": 19, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS005", + "S01-C12-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P05", + "type": "event", + "eventId": "S01-H47", + "emotionMomentId": "", + "assetName": "S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg", + "caption": "十个人围着真实饭桌,赵伯熟练按下加号;乐乐只按住自己的手,先问每份多大。", + "secondaryCaption": "", + "interactionPrompt": "因“七十周年不能空”继续加菜,这样妥当吗?", + "shortDialogue": "赵伯说:“十四不好听,再来俩。”乐乐按住减号:“十个人十四道,已经不是数学问题了。”", + "hotspot": { + "x": 15, + "y": 0, + "w": 69, + "h": 99 + }, + "programEvidenceInset": { + "kind": "order-count", + "kicker": "画中近景", + "screenLabel": "手机点单", + "countLabel": "已点", + "countValue": 14, + "countUnit": "道", + "plusGlyph": "+", + "note": "赵伯还在加菜", + "anchor": "top-right", + "ariaLabel": "画中近景:手机显示已点十四道,赵伯还要按加号继续加菜。" + }, + "audioCueIds": [ + "S01-C12-MS007", + "S01-C12-MS008", + "S01-C12-MS009" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P06", + "type": "event", + "eventId": "S01-H48", + "emotionMomentId": "", + "assetName": "S01-C12-P06-H48-card-is-not-a-promise-v1.jpg", + "caption": "明远把信息卡往父亲面前一放,像找到了省心答案;乐乐却指着空白栏,等他把话听完。", + "secondaryCaption": "", + "interactionPrompt": "看到“烹调方式:炖;本份约供2—3人;可选小份”,就理解成“健康保证”,这样妥当吗?", + "shortDialogue": "明远说:“写得这么健康,多点一份没事。”乐乐问:“它说怎么做、多大份,又没说能治病吧?”", + "hotspot": { + "x": 18, + "y": 0, + "w": 67, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS010", + "S01-C12-MS011", + "S01-C12-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM12", + "assetName": "S01-C12-P07-EM12-hand-choice-back-v1.jpg", + "caption": "手机、语音、纸单和服务员都在;乐乐把纸单放到赵伯手边,明远终于停下来等他开口。", + "secondaryCaption": "", + "interactionPrompt": "信息都在了,最后一步怎样交还给赵伯?", + "shortDialogue": "", + "hotspot": { + "x": 18, + "y": 0, + "w": 67, + "h": 99 + }, + "audioCueIds": [ + "S01-C12-MS005", + "S01-C12-MS006", + "S01-C12-TE900" + ], + "tableEcho": "让人看懂、让人开口,才算把选择递到手里。", + "cliffhanger": "" + }, + { + "pageId": "S01-C12-P08", + "type": "memory-card-and-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C12-P08-rolled-speech-cliffhanger-v1.jpg", + "caption": "让人看懂、让人开口,才算把选择递到手里。", + "secondaryCaption": "", + "interactionPrompt": "收进我的桂香岁月", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C12-MS013", + "S01-C12-MS014-ATTR", + "S01-C12-MS014", + "S01-C12-MS015", + "S01-C12-MS016" + ], + "tableEcho": "让人看懂、让人开口,才算把选择递到手里。", + "cliffhanger": "秦师傅把稿子重新卷起,只说:“先上菜。”林秀兰看见末行写着“请晚班的人原谅我”。" + } + ] + }, + "S01-C13": { + "pages": [ + { + "pageId": "S01-C13-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P01-three-chopsticks-collide-v1.jpg", + "caption": "乐乐刚说饱,三双公筷却同时伸来;只听“叮”的一声,桌边静了半拍。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MT000", + "S01-C13-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P02-opening-ensemble-v1.jpg", + "caption": "乐乐护碗,明远推盘,赵伯的姓名牌去了侧桌;小满手里的软尺,还没有展开。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁还在替人夹,谁把自己的菜推过去,谁又让姓名牌先离了席。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MS002", + "S01-C13-MS003", + "S01-C13-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P03", + "type": "event", + "eventId": "S01-H49", + "emotionMomentId": "", + "assetName": "S01-C13-P03-H49-ask-before-serving-v1.jpg", + "caption": "三位长辈都想照顾孩子,却没有一个先问;公筷干净,也不能替乐乐说“要”。", + "secondaryCaption": "", + "interactionPrompt": "不问乐乐就轮流替他夹菜,这样妥当吗?", + "shortDialogue": "赵伯笑:“还排上号了。”乐乐护住碗:“公筷也是筷子,先问我呀!”", + "hotspot": { + "x": 14, + "y": 3, + "w": 70, + "h": 94 + }, + "audioCueIds": [ + "S01-C13-MS001", + "S01-C13-MS002", + "S01-C13-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P04", + "type": "event", + "eventId": "S01-H50", + "emotionMomentId": "", + "assetName": "S01-C13-P04-H50-name-card-leaves-first-v1.jpg", + "caption": "小满想把照顾安排周全,却先把姓名牌移去侧桌;赵伯本人仍坐在大家中间。", + "secondaryCaption": "", + "interactionPrompt": "因担心赵伯,未经商量就把他安排到隔开的桌上,这样妥当吗?", + "shortDialogue": "赵伯探身说:“我人还在这儿,牌先被请走了?”小满的手停在半空。", + "hotspot": { + "x": 43, + "y": 1, + "w": 54, + "h": 98 + }, + "programDetailInset": { + "label": "桌边近景", + "x": 65, + "y": 36, + "w": 35, + "h": 39, + "anchor": "top-right", + "labelAnchor": "bottom-left" + }, + "audioCueIds": [ + "S01-C13-MS006", + "S01-C13-MS007", + "S01-C13-MS008" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P05", + "type": "event", + "eventId": "S01-H51", + "emotionMomentId": "", + "assetName": "S01-C13-P05-H51-family-double-standard-v1.jpg", + "caption": "明远把自己的甜饮举到嘴边,另一只手还搭在推给乐乐的菜盘上;乐乐抬眼看他。", + "secondaryCaption": "", + "interactionPrompt": "大人提醒孩子少喝,自己却仍举着甜饮,这样妥当吗?", + "shortDialogue": "明远说:“饮料少喝。”乐乐看着他的瓶子:“爸爸,那咱们一起少喝,好吗?”", + "hotspot": { + "x": 18, + "y": 0, + "w": 68, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS004", + "S01-C13-MS005" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P06", + "type": "event", + "eventId": "S01-H52", + "emotionMomentId": "", + "assetName": "S01-C13-P06-H52-tape-stays-rolled-v1.jpg", + "caption": "小满看着姓名牌和卷起的软尺,没有先量侧桌;这一回,她决定先问坐桌的人。", + "secondaryCaption": "", + "interactionPrompt": "不先挪人,先问赵伯想坐哪里,再调整共同桌,这样更稳妥吗?", + "shortDialogue": "小满问:“赵伯,您想坐哪儿?”赵伯把椅子往里收了收:“我还坐大家旁边。”", + "hotspot": { + "x": 24, + "y": 0, + "w": 66, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS009", + "S01-C13-MS010", + "S01-C13-MS011", + "S01-C13-MS012", + "S01-C13-MS013", + "S01-C13-MS014", + "S01-C13-MS015" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM13", + "assetName": "S01-C13-P07-EM13-listen-to-child-v1.jpg", + "caption": "三双公筷终于都停下。乐乐还护着碗,等大人第一次不替他说话。", + "secondaryCaption": "", + "interactionPrompt": "这一回,家里人怎样让乐乐自己选?", + "shortDialogue": "", + "hotspot": { + "x": 24, + "y": 0, + "w": 54, + "h": 99 + }, + "audioCueIds": [ + "S01-C13-MS010", + "S01-C13-MS011", + "S01-C13-MS012", + "S01-C13-TE900" + ], + "tableEcho": "这一次,大人终于听孩子把话说完。", + "cliffhanger": "" + }, + { + "pageId": "S01-C13-P08", + "type": "memory-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C13-P08-cook-crosses-threshold-v1.jpg", + "caption": "为你好之前,先听一句“我要不要”;照顾也要把选择还给人。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C13-MS016", + "S01-C13-MS017", + "S01-C13-MS018" + ], + "tableEcho": "", + "cliffhanger": "秦师傅端起一锅白菜热汤面,终于跨过躲了整晚的后厨门槛。" + } + ] + }, + "S01-C14": { + "pages": [ + { + "pageId": "S01-C14-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P01-lid-opens-old-scent-v1.jpg", + "caption": "锅盖一揭,白菜热汤面冒起白汽;赵伯一句“就这”,忽然停在了半截。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MT000", + "S01-C14-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P02-responsibility-ensemble-v1.jpg", + "caption": "秦师傅扶住旧板,林秀兰排开证据;唐守安停在门框,赵伯从普通座端茶起身。", + "secondaryCaption": "", + "interactionPrompt": "先看人物:谁在说自己的责任,谁只补证据,谁没有往主位走,谁仍在桌上举杯。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MS002", + "S01-C14-MS003", + "S01-C14-MS004", + "S01-C14-MS005", + "S01-C14-MS006", + "S01-C14-MS007-ATTR", + "S01-C14-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P03", + "type": "event", + "eventId": "S01-H53", + "emotionMomentId": "", + "assetName": "S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg", + "caption": "秦师傅只讲白菜、面、热汤、做法与份量;旧味能留人情,不能替代治疗。", + "secondaryCaption": "", + "interactionPrompt": "因为是老菜,就宣传成“祖传降糖面”,这样妥当吗?", + "shortDialogue": "秦师傅说:“就是白菜、面和一锅热汤。讲做法、讲份量,别给它封神。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 68, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS001", + "S01-C14-MS002", + "S01-C14-MS003" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P04", + "type": "event", + "eventId": "S01-H54", + "emotionMomentId": "", + "assetName": "S01-C14-P04-H54-evidence-chain-v1.jpg", + "caption": "林秀兰把两枚破票、铜牌、红漆、孔位、板字与照片逐项核对;没有一件物证独自定案。", + "secondaryCaption": "", + "interactionPrompt": "把票据、铜牌、暗红漆、孔位、板字和照片一起核对,这样更稳妥吗?", + "shortDialogue": "林秀兰说:“每件东西只说自己知道的那一段,合起来,故事才站得稳。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 71, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS004", + "S01-C14-MS007-ATTR", + "S01-C14-MS007" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P05", + "type": "event", + "eventId": "S01-H55", + "emotionMomentId": "", + "assetName": "S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg", + "caption": "唐守安停在门框,没有去主位;赵伯拉开身边的普通座,秦师傅的话仍由秦师傅说完。", + "secondaryCaption": "", + "interactionPrompt": "大家请他坐主位,他选择普通座并让秦师傅自己说完,这样更稳妥吗?", + "shortDialogue": "赵伯喊:“给唐大夫留个座。”唐守安摆手:“普通座就好,先听老秦把话说完。”", + "hotspot": { + "x": 48, + "y": 1, + "w": 48, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS008", + "S01-C14-MS009", + "S01-C14-MS010" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P06", + "type": "event", + "eventId": "S01-H56", + "emotionMomentId": "", + "assetName": "S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg", + "caption": "赵伯把蓝花盖碗转半圈,主动同大家碰杯;不用酒证明能耐,他也没有离开这张桌。", + "secondaryCaption": "", + "interactionPrompt": "用茶参加碰杯,也不再要求别人喝酒,这样更稳妥吗?", + "shortDialogue": "赵伯说:“这回我当个不劝酒、不硬撑的骨干。老伙计,茶也算一杯。”", + "hotspot": { + "x": 18, + "y": 1, + "w": 65, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS011", + "S01-C14-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM14", + "assetName": "S01-C14-P07-EM14-watch-face-down-v1.jpg", + "caption": "父子同高坐下,那只曾让问话匆匆结束的手表,还亮在两人之间。", + "secondaryCaption": "", + "interactionPrompt": "唐守安怎样让儿子把那句旧话真正说完?", + "shortDialogue": "", + "hotspot": { + "x": 8, + "y": 1, + "w": 84, + "h": 98 + }, + "audioCueIds": [ + "S01-C14-MS012", + "S01-C14-MS013", + "S01-C14-MS014", + "S01-C14-MS015", + "S01-C14-TE900" + ], + "tableEcho": "会解决问题的人,也可以学着先听一会儿。", + "cliffhanger": "" + }, + { + "pageId": "S01-C14-P08", + "type": "memory-cliffhanger", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C14-P08-measure-the-empty-place-v1.jpg", + "caption": "会解决问题,也要先听一会儿;桌子能否回来,先看那块空位最终坐回谁。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C14-MS016", + "S01-C14-MS017" + ], + "tableEcho": "", + "cliffhanger": "秦师傅问:“十五桌还能回来吗?”小满没有回答,先拿软尺重新量空位。" + } + ] + }, + "S01-C15": { + "pages": [ + { + "pageId": "S01-C15-P01", + "type": "chapter-title", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P01-chair-opens-for-late-workers-v1.jpg", + "caption": "三周后,晚市将收;小满先拉开椅子,门口那几个人终于不用再等。", + "secondaryCaption": "", + "interactionPrompt": "翻开这一回", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C15-MT000", + "S01-C15-MS001" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P02", + "type": "ensemble", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P02-everyone-makes-room-ensemble-v1.jpg", + "caption": "小满把椅子朝共同桌拉开,赵伯抬手招呼;两位晚班工友走进门,来晚的人也有位置。", + "secondaryCaption": "", + "interactionPrompt": "看看小满把椅子拉向哪里,再看门口的人正往哪里走。", + "shortDialogue": "", + "hotspot": null, + "audioCueIds": [ + "S01-C15-MS002", + "S01-C15-MS003", + "S01-C15-MS004", + "S01-C15-MS005", + "S01-C15-MS006" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P03", + "type": "event", + "eventId": "S01-H57", + "emotionMomentId": "", + "assetName": "S01-C15-P03-H57-three-real-portions-v1.jpg", + "caption": "小甄先看来了几个人,再把普通份、小份、半份三种真实餐盘推到大家眼前。", + "secondaryCaption": "", + "interactionPrompt": "菜单提供多种份量并写建议用餐人数,这样更稳妥吗?", + "shortDialogue": "小甄说:“先看几个人、每份多大。少点不够再添,比猜一桌需要多少更踏实。”", + "hotspot": { + "x": 17, + "y": 1, + "w": 55, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS003", + "S01-C15-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P04", + "type": "event", + "eventId": "S01-H58", + "emotionMomentId": "", + "assetName": "S01-C15-P04-H58-water-within-reach-v1.jpg", + "caption": "小甄把白水壶放到共同桌边;其他饮品仍在侧柜,先问本人,再决定要不要。", + "secondaryCaption": "", + "interactionPrompt": "默认让白水容易取得,酒和甜饮由本人选择,不替所有人自动摆上,这样更稳妥吗?", + "shortDialogue": "小甄说:“白水先放手边,其他饮品看清信息、问过本人,再决定要不要。”", + "hotspot": { + "x": 41, + "y": 1, + "w": 49, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS003", + "S01-C15-MS004" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P05", + "type": "event", + "eventId": "S01-H59", + "emotionMomentId": "", + "assetName": "S01-C15-P05-H59-ask-before-serving-v1.jpg", + "caption": "明远的公筷停在乐乐碗外。他先看孩子的脸,再问:“这个还要吗?”", + "secondaryCaption": "", + "interactionPrompt": "夹菜前先问乐乐还要不要,这样更稳妥吗?", + "shortDialogue": "明远问:“这个还要吗?”乐乐点头:“要一点,我自己夹。”明远把公筷递过去。", + "hotspot": { + "x": 12, + "y": 1, + "w": 58, + "h": 98 + }, + "programDetailInset": { + "label": "公筷停住", + "x": 22, + "y": 38, + "w": 51, + "h": 57, + "anchor": "top-left" + }, + "audioCueIds": [ + "S01-C15-MS007", + "S01-C15-MS008", + "S01-C15-MS009", + "S01-C15-MS010", + "S01-C15-MS011", + "S01-C15-MS012" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P06", + "type": "event", + "eventId": "S01-H60", + "emotionMomentId": "", + "assetName": "S01-C15-P06-H60-check-before-packing-v1.jpg", + "caption": "员工没有把剩菜一股脑装盒;她逐盘询问,也把保存和再加热提示一项项说明。", + "secondaryCaption": "", + "interactionPrompt": "所有剩菜不分情况都必须打包,这样妥当吗?", + "shortDialogue": "员工问:“这些要带走吗?我先把能不能保存、怎么再加热给您说清。”", + "hotspot": { + "x": 31, + "y": 1, + "w": 58, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS016" + ], + "tableEcho": "", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P07", + "type": "emotion", + "eventId": "", + "emotionMomentId": "S01-EM15", + "assetName": "S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg", + "caption": "同一机位里,人已坐满;老吕带来的右缺口蓝边旧碗,还是空的。", + "secondaryCaption": "", + "interactionPrompt": "这只旧碗怎样留在今天的第十五桌边?", + "shortDialogue": "", + "hotspot": { + "x": 12, + "y": 1, + "w": 76, + "h": 98 + }, + "audioCueIds": [ + "S01-C15-MS013", + "S01-C15-MS014", + "S01-C15-MS015", + "S01-C15-MS016", + "S01-C15-TE900", + "S01-C15-MS017" + ], + "tableEcho": "桌子回来了,人也都坐回来了。", + "cliffhanger": "" + }, + { + "pageId": "S01-C15-P08", + "type": "memory-season-close", + "eventId": "", + "emotionMomentId": "", + "assetName": "S01-C15-P08-people-seated-season-close-v2.jpg", + "caption": "第一回少的桌,如今有人坐回来了。铜牌回到桌沿,晚班工友也坐回来了。", + "secondaryCaption": "", + "interactionPrompt": "", + "shortDialogue": "", + "hotspot": null, + "programDetailInset": { + "label": "秦师傅", + "x": 72, + "y": 7, + "w": 25, + "h": 44, + "anchor": "top-right" + }, + "audioCueIds": [ + "S01-C15-MS016", + "S01-C15-TE900", + "S01-C15-MS017", + "S01-C15-MS018" + ], + "tableEcho": "", + "cliffhanger": "快门按下,旧铜牌“15”重新固定。原来缺的不是一张桌,是这些人应该有的位置。" + } + ] + } +} diff --git a/TongjiUniApp/native/tang-detective/package-game/data/releaseAssetManifest.js b/TongjiUniApp/native/tang-detective/package-game/data/releaseAssetManifest.js new file mode 100644 index 0000000..0472d12 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-game/data/releaseAssetManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-game/data/remotePageAudioManifest.js b/TongjiUniApp/native/tang-detective/package-game/data/remotePageAudioManifest.js new file mode 100644 index 0000000..490fcb6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-game/data/remotePageAudioManifest.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapter.js b/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapter.js new file mode 100644 index 0000000..aa602fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapter.js @@ -0,0 +1,2302 @@ +const season = require('../../../data/season') +const { + getProgress, + saveProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} = require('../../../utils/storage') +const { + getChapterLayoutMetrics, +} = require('../../../utils/layout') +const { + getComicArtStageStyle, +} = require('../../../utils/comicLayout') +const { + addMemoryCard, +} = require('../../../utils/memoryCollection') +const { + chapterRoute, + normalizeChapterNumber, +} = require('../../../utils/chapterRoute') +const { + buildComicPageModel, +} = require('./chapterPages') +const { + applyComicReaderAction, + buildComicReaderState, + mergeComicReaderProgress, + readComicReaderProgress, +} = require('../../utils/comicReaderState') +const { + releaseAssets, +} = require('../../data/releaseAssetManifest') +const { + getApprovedRemotePageAudio, +} = require('../../data/remotePageAudioManifest') +const assetReleaseConfig = require('../../data/assetReleaseConfig') +const { + createAssetPlatformFacade, + createAssetManager, +} = require('../../utils/assetManager') +const { + IMAGE_UNAVAILABLE_MESSAGE, + buildComicImageState, + buildReviewedAudioState, + getComicFocusImageStyle, + getComicPageImageMode, + getComicPageImageStyle, +} = require('../../utils/comicPageModel') + +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' +const SHARE_PREVIEW_CACHE_NAME = 'guixiang-story-share-preview-v1.jpg' +const MEMORY_SHARE_SOURCE = 'chapter-memory' +const STORY_FIRST_PRESENTATION = 'front-back-comic-v1' +// C01 full-page tracks live behind real player pages in two sibling +// subpackages. The reader may navigate to those pages, but must never read a +// sibling package's MP3 path directly (WeChat forbids cross-package assets). +const CHAPTER_ONE_AUDIO_COMPANIONS = Object.freeze({ + 'S01-C01-P01': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 33.679388 }), + 'S01-C01-P02': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 49.49424 }), + 'S01-C01-P03': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 30.534762 }), + 'S01-C01-P04': Object.freeze({ packageRoot: 'package-audio-c01-a', durationSeconds: 46.79424 }), + 'S01-C01-P05': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 32.880567 }), + 'S01-C01-P06': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 13.328889 }), + 'S01-C01-P07': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 10.466644 }), + 'S01-C01-P08': Object.freeze({ packageRoot: 'package-audio-c01-b', durationSeconds: 39.534467 }), +}) +const CHAPTER_ONE_EVENT_COPY = Object.freeze({ + 'S01-H01': Object.freeze({ + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + actionDescription: '小满只把二维码转向陆续进门的老人,纸名单还压在桌角。', + evidence: '几位老人举着手机半天没动,桌角的大字纸名单还没有铺开。', + retryHint: '先别急。有人举着手机半天没动,桌角那张纸名单还没铺开。', + reason: '这下有座可找,也不用求人替自己点。只留扫码口,看不清小字或不熟手机的人,连自己找座的机会都没了。', + actionAdvice: '扫码、纸名单、有人可问,三样都留着。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先让大家扫着,实在不会再来问。' }), + Object.freeze({ value: 'caution', label: '把大字纸名单铺开,再问一句要不要帮忙。' }), + ]), + }), + 'S01-H02': Object.freeze({ + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + actionDescription: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上。', + evidence: '十个人已经点了十四道菜;赵伯惦记的是桌面体面,不是有人没吃饱。', + retryHint: '您再数数:十个人,十四道菜。赵伯惦记的是体面,不是有人没吃饱。', + reason: '热闹留住了,菜也没白白堆满桌。情分不在菜多菜少,点得太满,反倒挤掉大家真正想吃的。', + actionAdvice: '先看人数、份量和搭配;真不够,再补也来得及。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '难得聚一回,再添一道才显得热闹。' }), + Object.freeze({ value: 'caution', label: '先问问够不够,不够咱再添。' }), + ]), + }), + 'S01-H03': Object.freeze({ + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + actionDescription: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + evidence: '地毯上有四个新压痕,椅脚拖痕通向侧门;请柬和座位图也对不上。', + retryHint: '只顺着一条拖痕走,容易漏掉请柬和跳号的座位图。', + reason: '三样东西一放到一块儿,桌子来过没有,就有了眉目。光看手机那张图,还说明不了全貌。', + actionAdvice: '先拍照留住压痕和拖痕,再查当天的调台记录。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'caution', label: '顺着拖痕追过去,先看看桌子搬到哪儿了。' }), + Object.freeze({ value: 'safer', label: '先拍下压痕,再把请柬和座位图放一块儿对对。' }), + ]), + }), + 'S01-H04': Object.freeze({ + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + actionDescription: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + evidence: '铜牌和未念完的道歉稿卷在一起,稿首写着“给十五桌的老伙计们”。', + retryHint: '铜牌和道歉稿原本卷在一起,看着更像话到嘴边,又咽了回去。', + reason: '这下有眉目了。事情还没问清,先别急着给谁扣帽子;这些东西也不能证明是谁搬走了桌子。', + actionAdvice: '别拆散铜牌、纸筒和稿纸,记清来路,再等秦师傅把话说完。', + choiceOptions: Object.freeze([ + Object.freeze({ value: 'safer', label: '先问秦师傅这份稿子写给谁,再看铜牌怎么来的。' }), + Object.freeze({ value: 'caution', label: '铜牌既然在这儿,先去问是谁把桌子搬走的。' }), + ]), + }), +}) +const CHAPTER_ONE_EMOTION_CHOICES = Object.freeze([ + Object.freeze({ + choiceId: 'S01-EM01-A', + text: '喊一声“赵伯”,把请柬递给他:“您带我们一块儿看看。”', + }), + Object.freeze({ + choiceId: 'S01-EM01-B', + text: '先拉一把普通椅子,再问他愿不愿意坐下等。', + }), +]) + +// The internal values stay locked to the reviewed season data. Only the +// player-facing question and the two ordinary actions change here, so the +// interaction reads like a conversation about the scene instead of a quiz. +const STORY_FIRST_EVENT_COPY = Object.freeze({ + 'S01-H05': { + question: '展签把厂内饭菜票写成全国粮票,您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '照着展签,继续说是全国粮票' }, + { value: 'caution', label: '先看票面,再把展签改准' }, + ], + }, + 'S01-H06': { + question: '赵伯说缺口碗是他的,您先怎么认?', + choiceOptions: [ + { value: 'safer', label: '听他说完,马上写上名字' }, + { value: 'caution', label: '翻过照片,再对一遍旧账' }, + ], + }, + 'S01-H07': { + question: '合影里露出半张脸和几件旧物,您先记什么?', + choiceOptions: [ + { value: 'safer', label: '把布包和桌沿都记下来' }, + { value: 'caution', label: '只看半张脸,就把人认定' }, + ], + }, + 'S01-H08': { + question: '铜牌背面有红漆、钉孔和破票,您先怎么留证?', + choiceOptions: [ + { value: 'safer', label: '拍下漆痕孔位,留着再核对' }, + { value: 'caution', label: '只看一处红漆,就定了来历' }, + ], + }, + 'S01-H09': { + question: '老吕手上还沾着机油,就伸手拿馒头。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '赶时间,先拿馒头再说' }, + { value: 'caution', label: '先把手洗净擦干,再吃饭' }, + ], + }, + 'S01-H10': { + question: '同坐一条长凳,有人要起身。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '不用招呼,直接站起来' }, + { value: 'caution', label: '先喊坐稳,再慢慢起身' }, + ], + }, + 'S01-H11': { + question: '看见当年工人吃大盆主食,您会怎么说?', + choiceOptions: [ + { value: 'safer', label: '按今天的吃法,笑他们饭多' }, + { value: 'caution', label: '先看当年供应和下午的重活' }, + ], + }, + 'S01-H12': { + question: '饭菜票和晚班人数单到了手里,您怎么收?', + choiceOptions: [ + { value: 'safer', label: '两种凭据分开夹、分开核对' }, + { value: 'caution', label: '两种票单混在一起收' }, + ], + }, + 'S01-H13': { + question: '工友又起哄比谁先吃第三碗,您会怎么接?', + choiceOptions: [ + { value: 'safer', label: '跟着起哄,比谁先吃第三碗' }, + { value: 'caution', label: '不跟着比,先问自己还饿不饿' }, + ], + }, + 'S01-H14': { + question: '赵建国已经吃撑,还要马上抬货。您先怎么办?', + choiceOptions: [ + { value: 'safer', label: '嘴上说没事,马上去抬货' }, + { value: 'caution', label: '先停下添饭,说清哪里难受' }, + ], + }, + 'S01-H15': { + question: '小唐说“别又急又撑”,这句话该怎么听?', + choiceOptions: [ + { value: 'safer', label: '听成主食一口都不能吃' }, + { value: 'caution', label: '听成别急别撑,再看自己需要' }, + ], + }, + 'S01-H16': { + question: '同伴扶住桌沿,您先怎么给他留面子?', + choiceOptions: [ + { value: 'safer', label: '拉把凳子,先问要不要歇' }, + { value: 'caution', label: '继续拿他起哄,催他干活' }, + ], + }, + 'S01-H17': { + question: '老吕错过饭点,还想空着肚子去上晚班。您怎么留?', + choiceOptions: [ + { value: 'safer', label: '什么也不说,空着肚子硬扛' }, + { value: 'caution', label: '告诉带班人,先吃饭休息' }, + ], + }, + 'S01-H18': { + question: '只剩半个馒头,几位晚班工友怎么分?', + choiceOptions: [ + { value: 'safer', label: '都留给最能干的那个人' }, + { value: 'caution', label: '先数清人数,再一起商量' }, + ], + }, + 'S01-H19': { + question: '午间熟菜放了多久说不清,秦师傅怎么做?', + choiceOptions: [ + { value: 'safer', label: '拿另放妥当的白菜和面现做' }, + { value: 'caution', label: '把放了多久不清楚的饭再热' }, + ], + }, + 'S01-H20': { + question: '晚班的人终于回来了,这张桌该怎么摆?', + choiceOptions: [ + { value: 'safer', label: '把桌摆好,让晚班人坐下吃' }, + { value: 'caution', label: '让来晚的人端碗站在门口' }, + ], + }, + 'S01-H21': { + question: '秦师傅签了经营责任书,旧桌就归他了吗?', + choiceOptions: [ + { value: 'safer', label: '签完字,就当旧桌归自己' }, + { value: 'caution', label: '对着合同和清单逐项核对' }, + ], + }, + 'S01-H22': { + question: '老工友还递饭票,您会怎么帮他?', + choiceOptions: [ + { value: 'safer', label: '笑他没跟上,让他自己琢磨' }, + { value: 'caution', label: '说清付款办法,陪他办一次' }, + ], + }, + 'S01-H23': { + question: '饭馆开业再忙,员工这顿饭怎么安排?', + choiceOptions: [ + { value: 'safer', label: '先排好替班,让员工坐下吃饭' }, + { value: 'caution', label: '忙起来,就靠尝菜顶一顿' }, + ], + }, + 'S01-H24': { + question: '客人想拿员工桌拼大桌,秦师傅怎么安排?', + choiceOptions: [ + { value: 'safer', label: '另给客人拼桌,员工桌照留' }, + { value: 'caution', label: '先撤员工桌,等忙完再说' }, + ], + }, + 'S01-H25': { + question: '孩子已经说饱了,碗里还有饭。您会怎么做?', + choiceOptions: [ + { value: 'safer', label: '再撑也得把碗吃见底' }, + { value: 'caution', label: '说饱就停,下回先少盛' }, + ], + }, + 'S01-H26': { + question: '赵伯举着饭勺,还想给孩子添饭。您怎么拦?', + choiceOptions: [ + { value: 'safer', label: '不问孩子,拿勺接着添' }, + { value: 'caution', label: '先问还要不要,同意再添' }, + ], + }, + 'S01-H27': { + question: '孩子的话还没说完,唐守安已经在看表。您怎么做?', + choiceOptions: [ + { value: 'safer', label: '边看表边问,没听完就走' }, + { value: 'caution', label: '放下表,听孩子把话说完' }, + ], + }, + 'S01-H28': { + question: '饭馆忙起来,员工在哪儿吃、什么时候吃?', + choiceOptions: [ + { value: 'safer', label: '清出员工桌,轮流坐下吃' }, + { value: 'caution', label: '客人一多,员工就站着吃' }, + ], + }, + 'S01-H29': { + question: '菜已经摆满,主家还怕别人说小气。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '怕人说小气,再添几道菜' }, + { value: 'caution', label: '先看人数份量,真缺再添' }, + ], + }, + 'S01-H30': { + question: '剩菜要不要带,主家只怕没面子。您怎么接?', + choiceOptions: [ + { value: 'safer', label: '觉得打包丢脸,剩菜全不要' }, + { value: 'caution', label: '先问哪些能带,再照提示存' }, + ], + }, + 'S01-H31': { + question: '套餐只写菜名,主家看不出够几个人。您先问什么?', + choiceOptions: [ + { value: 'safer', label: '只看菜名,马上把套餐定下' }, + { value: 'caution', label: '问清盘量和人数,再决定' }, + ], + }, + 'S01-H32': { + question: '婚宴临时加桌,员工的座位怎么办?', + choiceOptions: [ + { value: 'safer', label: '婚宴加桌,先把员工桌挪走' }, + { value: 'caution', label: '先留员工座位,再安排加桌' }, + ], + }, + 'S01-H33': { + question: '车钥匙还在桌上,酒杯又被推过来。您怎么选?', + choiceOptions: [ + { value: 'safer', label: '还要开车,也尝一口白酒' }, + { value: 'caution', label: '撤下酒杯,换水再开车' }, + ], + }, + 'S01-H34': { + question: '唐守安已经拒酒,这桌人接下来怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '递上热茶,照样碰杯聊天' }, + { value: 'caution', label: '继续劝酒,非喝一口才算' }, + ], + }, + 'S01-H35': { + question: '酒局要开始,赵伯想自己改药。您怎么劝?', + choiceOptions: [ + { value: 'safer', label: '为了酒局,自己把药量改改' }, + { value: 'caution', label: '照原有安排,有疑问问医生药师' }, + ], + }, + 'S01-H36': { + question: '赵伯出汗、手抖、反应慢,眼前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先停酒移开杯子,留人陪着' }, + { value: 'caution', label: '还当醉酒,继续劝他喝' }, + ], + }, + 'S01-H37': { + question: '炊事员总在传菜口站着吃,怎么给她留顿饭?', + choiceOptions: [ + { value: 'safer', label: '趁传菜空当,站着几口吃完' }, + { value: 'caution', label: '清出座位替班,坐下吃完' }, + ], + }, + 'S01-H38': { + question: '赵伯又拿大碗给明远添饭,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '拿大碗添满,夸能吃有本事' }, + { value: 'caution', label: '先问要不要,再按需要盛' }, + ], + }, + 'S01-H39': { + question: '订席电话又响,明远这顿冷饭怎么办?', + choiceOptions: [ + { value: 'safer', label: '电话一响,就放下饭继续接' }, + { value: 'caution', label: '安排人替接,留出吃饭时间' }, + ], + }, + 'S01-H40': { + question: '嘴上说给员工留桌,桌面却堆满酒箱。怎么办?', + choiceOptions: [ + { value: 'safer', label: '酒箱照堆,嘴上说留着就行' }, + { value: 'caution', label: '清空桌面替班,让人坐下' }, + ], + }, + 'S01-H41': { + question: '“健康、互助”不能只挂墙上,还要落在哪儿?', + choiceOptions: [ + { value: 'safer', label: '只把展板擦亮,服务照旧' }, + { value: 'caution', label: '把座位、纸单和帮忙安排好' }, + ], + }, + 'S01-H42': { + question: '旧桌有感情,可还没检查。秦师傅先怎么办?', + choiceOptions: [ + { value: 'safer', label: '舍不得旧桌,直接给客人用' }, + { value: 'caution', label: '先停用检查,再决定怎么留' }, + ], + }, + 'S01-H43': { + question: '饭店翻建,材料和行人通道怎么分?', + choiceOptions: [ + { value: 'safer', label: '围好材料区,给人留通道' }, + { value: 'caution', label: '材料靠通道堆,大家绕着走' }, + ], + }, + 'S01-H44': { + question: '旧桌不能再用,留下来的物件怎么收?', + choiceOptions: [ + { value: 'safer', label: '登记来源,把桌板铜牌存好' }, + { value: 'caution', label: '签完处置单,旧物随手丢' }, + ], + }, + 'S01-H45': { + question: '库房钥匙在手,开柜前先做什么?', + choiceOptions: [ + { value: 'safer', label: '先问同意,再逐件拍照核对' }, + { value: 'caution', label: '拿钥匙就开柜,只看一个孔' }, + ], + }, + 'S01-H46': { + question: '饭店用了扫码点餐,老人不熟手机怎么办?', + choiceOptions: [ + { value: 'safer', label: '扫码旁摆纸单,留人工点餐和现金' }, + { value: 'caution', label: '只留扫码牌,让老人自己试' }, + ], + }, + 'S01-H47': { + question: '十个人点了十四道菜,赵伯还嫌数字不好听。怎么劝?', + choiceOptions: [ + { value: 'safer', label: '嫌十四不好听,再加两道菜' }, + { value: 'caution', label: '看人数份量,真不够再添' }, + ], + }, + 'S01-H48': { + question: '菜单写着“炖、小份”,您会怎么用这些信息?', + choiceOptions: [ + { value: 'safer', label: '看见炖和小份,就当健康保证' }, + { value: 'caution', label: '把做法份量当信息来选' }, + ], + }, + 'S01-H49': { + question: '三双公筷一起伸向乐乐,您先说什么?', + choiceOptions: [ + { value: 'safer', label: '都别停,一起往孩子碗里夹' }, + { value: 'caution', label: '先问想不想吃,同意再夹' }, + ], + }, + 'S01-H50': { + question: '大家担心赵伯,座位该怎么安排?', + choiceOptions: [ + { value: 'safer', label: '没问赵伯,就把他挪到侧桌' }, + { value: 'caution', label: '先问想坐哪,再在同桌调整' }, + ], + }, + 'S01-H51': { + question: '明远叫孩子少喝甜饮,自己却照喝。全家怎么办?', + choiceOptions: [ + { value: 'safer', label: '只管孩子少喝,自己照喝甜饮' }, + { value: 'caution', label: '全家一起换,让孩子参与选' }, + ], + }, + 'S01-H52': { + question: '小满手里拿着软尺,先量座位还是先问赵伯?', + choiceOptions: [ + { value: 'safer', label: '先问赵伯想坐哪,再搬椅子' }, + { value: 'caution', label: '替他拿主意,直接挪座位' }, + ], + }, + 'S01-H53': { + question: '有人想把热汤面叫“祖传降糖面”,您怎么接?', + choiceOptions: [ + { value: 'safer', label: '把老汤面说成祖传降糖菜' }, + { value: 'caution', label: '只讲食材做法,不说能降糖' }, + ], + }, + 'S01-H54': { + question: '票据、铜牌、照片都摆齐了,怎么认这张旧桌?', + choiceOptions: [ + { value: 'safer', label: '票据铜牌照片一起核对' }, + { value: 'caution', label: '只凭一处红漆就定来历' }, + ], + }, + 'S01-H55': { + question: '秦师傅要说当年的事,唐守安坐哪里、怎么听?', + choiceOptions: [ + { value: 'safer', label: '让秦师傅说完,唐大夫坐旁边听' }, + { value: 'caution', label: '请唐大夫坐主位,替他说' }, + ], + }, + 'S01-H56': { + question: '这桌人饮品各不相同,怎么碰杯?', + choiceOptions: [ + { value: 'safer', label: '各拿自己的饮品,照样碰杯' }, + { value: 'caution', label: '把茶换成酒,再劝一轮' }, + ], + }, + 'S01-H57': { + question: '一家人饭量不一样,菜单份量怎么摆?', + choiceOptions: [ + { value: 'safer', label: '摆出三种份量,按人数来选' }, + { value: 'caution', label: '只留一种大份,大家统一点' }, + ], + }, + 'S01-H58': { + question: '开席前,桌上的饮品怎么摆?', + choiceOptions: [ + { value: 'safer', label: '先摆白水,其他有人要再拿' }, + { value: 'caution', label: '酒和甜饮先摆满桌' }, + ], + }, + 'S01-H59': { + question: '明远的公筷停在半空,下一步怎么做?', + choiceOptions: [ + { value: 'safer', label: '筷子先停住,问孩子还要不要' }, + { value: 'caution', label: '不等开口,直接夹进碗' }, + ], + }, + 'S01-H60': { + question: '桌上有剩菜,员工先怎么打包?', + choiceOptions: [ + { value: 'safer', label: '不管是什么,剩菜全打包' }, + { value: 'caution', label: '逐样问清能不能存,再打包' }, + ], + }, +}) + +function chapterOneChoiceOptions(eventId, fallbackOptions = []) { + const eventCopy = CHAPTER_ONE_EVENT_COPY[String(eventId || '')] + const fixed = eventCopy && eventCopy.choiceOptions + if (fixed) return fixed.map((option) => ({ ...option })) + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.label, + }), + ) +} + +function storyFirstChoiceOptions(eventId, fallbackOptions = []) { + if (CHAPTER_ONE_EVENT_COPY[String(eventId || '')]) { + return chapterOneChoiceOptions(eventId, fallbackOptions) + } + const eventCopy = STORY_FIRST_EVENT_COPY[String(eventId || '')] + if (eventCopy && Array.isArray(eventCopy.choiceOptions)) { + return eventCopy.choiceOptions.map((option) => ({ ...option })) + } + return (Array.isArray(fallbackOptions) ? fallbackOptions : []).map( + (option) => ({ + value: option.value, + label: option.value === 'safer' + ? '按画里的做法接着办。' + : '先停一停,再看看手边的情况。', + }), + ) +} + +function storyFirstQuestion(sourceEvent, pageQuestion = '') { + const fixed = CHAPTER_ONE_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (fixed && fixed.question) return fixed.question + const eventCopy = STORY_FIRST_EVENT_COPY[ + String(sourceEvent && sourceEvent.hotspotId || '') + ] + if (eventCopy && eventCopy.question) return eventCopy.question + const source = String( + pageQuestion || sourceEvent && sourceEvent.question || '', + ) + for (const ending of [',这样更稳妥吗?', ',这样妥当吗?']) { + if (source.endsWith(ending)) { + return `${source.slice(0, -ending.length)},您会怎么接着办?` + } + } + return source +} + +function chapterOneEventCopy(sourceEvent) { + if (!sourceEvent) return null + const fixed = CHAPTER_ONE_EVENT_COPY[String(sourceEvent.hotspotId || '')] + if (!fixed) return { ...sourceEvent } + const { choiceOptions, ...copy } = fixed + return { + ...sourceEvent, + ...copy, + choiceOptions: chapterOneChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ), + } +} + +function chapterOneDisplayChapter(chapter) { + if (!chapter || chapter.chapterId !== 'S01-C01') return chapter + const sourceMoment = chapter.emotionMoment + if (!sourceMoment) return { ...chapter } + const sourceChoices = Array.isArray(sourceMoment.playerChoices) + ? sourceMoment.playerChoices + : [] + return { + ...chapter, + emotionMoment: { + ...sourceMoment, + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + playerChoices: CHAPTER_ONE_EMOTION_CHOICES.map((copy) => ({ + ...(sourceChoices.find( + (item) => item.choiceId === copy.choiceId, + ) || {}), + ...copy, + })), + }, + } +} + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return ( + `/pages/share/share?cardId=${encodeURIComponent(cardId)}` + + `&source=${MEMORY_SHARE_SOURCE}` + ) +} + +function lifeReportPath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '' + return `/pages/report/report?cardId=${encodeURIComponent(cardId)}` +} + +function comicAssetIdForPage(page) { + const match = String(page && page.pageId).match( + /^S(\d+)-C(\d+)-P(\d+)$/, + ) + if (!match) return '' + return `comic.s${match[1]}.c${match[2]}.p${match[3]}` +} + +function formatTime(value) { + const seconds = Math.max(0, Math.floor(Number(value) || 0)) + const minutes = Math.floor(seconds / 60) + return `${minutes}:${String(seconds % 60).padStart(2, '0')}` +} + +function pageProgressData(readerState) { + return { + completedIds: [...readerState.completedEventIds], + } +} + +function textLength(value) { + return [...String(value || '')].length +} + +function pageActor(chapter, page) { + const people = chapter && Array.isArray(chapter.people) + ? chapter.people + : [] + const visualActorInstanceId = page + && page.playableVisual + && page.playableVisual.actorInstanceId + return people.find( + (person) => person.instanceId === ( + (page && page.actorInstanceId) || visualActorInstanceId + ), + ) || null +} + +function decisionResultNeedsScroll( + activeEvent, + resultMode, + metrics, + fontScale, +) { + if (!activeEvent || !resultMode || !metrics) return false + const compact = Boolean(metrics.compactHeight) + const horizontalPadding = compact ? 40 : 64 + const backdropPadding = compact ? 16 : 24 + const modalWidth = Math.max( + 1, + Number(metrics.windowWidth || 0) + - Number(metrics.safeLeft || 0) + - Number(metrics.safeRight || 0) + - backdropPadding, + ) + const panelWidth = Math.max(120, modalWidth * 0.4 - horizontalPadding) + const bodyFontSize = fontScale === 'xlarge' + ? (compact ? 18 : 20) + : (compact ? 17 : 18) + const charactersPerLine = Math.max( + 6, + Math.floor(panelWidth / bodyFontSize), + ) + const bodyLength = resultMode === 'retry' + ? textLength(activeEvent.retryHint) + : ( + textLength(activeEvent.reason) + + textLength(activeEvent.actionAdvice) + ) + const estimatedLines = Math.max( + 1, + Math.ceil(bodyLength / charactersPerLine), + ) + const footerHeight = compact ? 70 : 82 + const verticalPadding = compact ? 36 : 54 + const availableHeight = Math.max( + 90, + Number(metrics.windowHeight || 0) + - Number(metrics.modalTop || 0) + - Number(metrics.safeBottom || 0) + - footerHeight + - verticalPadding, + ) + const fixedResultHeight = resultMode === 'correct' + ? (compact ? 140 : 118) + : (compact ? 62 : 80) + const estimatedHeight = ( + fixedResultHeight + + estimatedLines * bodyFontSize * 1.48 + ) + return estimatedHeight > availableHeight +} + +Page({ + data: { + chapter: null, + chapterNumber: 1, + chapterCount: 15, + completedIds: [], + chapterFinished: false, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + fontScale: 'large', + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:37', + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: false, + chapterAudioSrc: '', + compactHeight: true, + windowHeight: 390, + pageStyle: '', + topbarStyle: '', + comicPageCount: 0, + currentPage: null, + currentPageIndex: 0, + currentPageNumber: 1, + currentPageId: '', + comicImageSrc: '', + comicImageFallback: '', + comicImageActorFallback: '', + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: '', + comicImageShouldResolveRemote: false, + comicPageImageMode: 'scaleToFill', + sharePreviewLocalPath: '', + comicPageImageStyle: '', + comicFocusImageStyle: '', + comicArtStageStyle: '', + comicPreviousDisabled: true, + comicNextDisabled: false, + comicNextLocked: false, + currentEventCompleted: false, + currentInteractionEnabled: false, + presentationMode: 'legacy-60-40-v1', + storyFirstComicMode: false, + chapterOneComicMode: false, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: null, + decisionResultNeedsScroll: false, + memoryCollected: false, + memoryFamilyOpen: false, + }, + + onLoad(options = {}) { + const progress = getProgress() + const chapterNumber = normalizeChapterNumber( + options.chapter || progress.lastChapter || 1, + ) + const settings = getSettings() + this._storyFrontTapHintSeen = settings.storyFrontTapHintSeen === true + this.setData({ fontScale: settings.fontScale || 'large' }) + this.applyLayoutMetrics() + this.loadChapter(chapterNumber, options.replay === '1') + this.prepareSharePreview() + }, + + onResize(event = {}) { + this.applyLayoutMetrics(event.size) + }, + + onShow() { + this._audioPageHidden = false + if (!this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = false + this.setData({ audioLoading: false }) + }, + + onHide() { + this._audioPageHidden = true + if (this.audioContext && this.data.audioLoading) { + this.destroyAudio() + return + } + this.pauseAudio(true) + }, + + onUnload() { + this.destroyAudio() + }, + + prepareSharePreview() { + const readyPath = String(this.data.sharePreviewLocalPath || '').trim() + if (readyPath) return Promise.resolve(readyPath) + if (this._sharePreviewPromise) return this._sharePreviewPromise + const userDataPath = String( + wx.env && wx.env.USER_DATA_PATH || '', + ).trim() + if (!userDataPath || typeof wx.getFileSystemManager !== 'function') { + return Promise.resolve('') + } + const destinationPath = `${userDataPath}/${SHARE_PREVIEW_CACHE_NAME}` + const fileSystem = wx.getFileSystemManager() + this._sharePreviewPromise = new Promise((resolve) => { + fileSystem.readFile({ + filePath: SHARE_PREVIEW_IMAGE, + success: ({ data }) => { + fileSystem.writeFile({ + filePath: destinationPath, + data, + success: () => resolve(destinationPath), + fail: () => resolve(''), + }) + }, + fail: () => resolve(''), + }) + }).then((localPath) => { + this._sharePreviewPromise = null + if (localPath) this.setData({ sharePreviewLocalPath: localPath }) + return localPath + }) + return this._sharePreviewPromise + }, + + loadChapter(chapterNumber, replayFromStart = false) { + const chapter = chapterOneDisplayChapter( + season.chapters[chapterNumber - 1], + ) + const comicModel = buildComicPageModel(chapter, chapterNumber) + const storyFirstComicMode = ( + comicModel.presentationMode === STORY_FIRST_PRESENTATION + ) + const chapterOneComicMode = Boolean( + storyFirstComicMode && chapter.chapterId === 'S01-C01', + ) + const chapterAudio = buildReviewedAudioState( + chapter.audio, + releaseAssets, + ) + this._comicModel = comicModel + const storedProgress = getProgress() + const storedReaderProgress = readComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + ) + if (replayFromStart) { + storedReaderProgress.currentPageId = comicModel.firstPageId + if (storyFirstComicMode) { + storedReaderProgress.completedEventIds = [] + storedReaderProgress.chapterFinished = false + } + } + const readerState = buildComicReaderState( + comicModel, + storedReaderProgress, + ) + const normalizedStorage = mergeComicReaderProgress( + storedProgress, + comicModel, + chapterNumber, + readerState, + ) + saveProgress(normalizedStorage) + const collectedMemoryCards = Array.isArray( + normalizedStorage.collectedMemoryCards, + ) + ? normalizedStorage.collectedMemoryCards + : [] + const chapterMemoryPage = comicModel.pageSequence.find( + (page) => page.type === 'memory', + ) + const chapterMemoryCardId = chapterMemoryPage + && chapterMemoryPage.memoryCard + ? chapterMemoryPage.memoryCard.cardId + : '' + this.setData({ + chapter, + chapterNumber, + chapterCount: season.chapters.length, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + activeEvent: null, + activePerson: null, + decisionOpen: false, + textReadingOpen: false, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + memoryFamilyOpen: false, + emotionResult: null, + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: formatTime( + chapterAudio.available ? chapter.audio.durationSeconds : 0, + ), + audioPercent: 0, + audioProgressStyle: 'width:0%', + chapterAudioAvailable: chapterAudio.available, + chapterAudioSrc: chapterAudio.src, + comicPageCount: comicModel.pageSequence.length, + presentationMode: comicModel.presentationMode || 'legacy-60-40-v1', + storyFirstComicMode, + chapterOneComicMode, + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: Boolean( + storyFirstComicMode && !this._storyFrontTapHintSeen, + ), + memoryCollected: Boolean( + chapterMemoryCardId + && collectedMemoryCards.includes(chapterMemoryCardId), + ), + }) + this.applyComicReaderState(readerState, false) + wx.pageScrollTo({ scrollTop: 0, duration: 0 }) + }, + + getComicPageData(pageIndex, readerState = this._readerState) { + const model = this._comicModel + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return {} + } + const safeIndex = Math.min( + model.pageSequence.length - 1, + Math.max(0, Number(pageIndex) || 0), + ) + const page = model.pageSequence[safeIndex] + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + const imageState = buildComicImageState( + page, + releaseAsset, + this.data, + ) + const currentPageActor = pageActor(this.data.chapter, page) + // C02-C15 full-page narration is remote-only and fail-closed. Legacy + // cue-level seeds may remain as provenance, but do not expose them as a + // complete page track. A page gets a player route only after both the page + // manifest and immutable release asset have independently been approved. + const chapterNumber = Number(this.data.chapterNumber) || 1 + const useRemotePageAudio = chapterNumber >= 2 + const pageAudio = useRemotePageAudio + ? { available: false, src: '' } + : buildReviewedAudioState( + { + status: page.audioStatus, + assetId: page.audioAssetId, + src: page.audioSrc, + }, + releaseAssets, + ) + const localAudioCompanion = ( + CHAPTER_ONE_AUDIO_COMPANIONS[page.pageId] || null + ) + const remoteAudioCompanion = useRemotePageAudio + ? getApprovedRemotePageAudio(page.pageId, releaseAssets) + : null + const audioCompanion = localAudioCompanion || remoteAudioCompanion + const audioCompanionPath = localAudioCompanion + ? `/${localAudioCompanion.packageRoot}/pages/player/player?pageId=${page.pageId}` + : (remoteAudioCompanion + ? `/package-audio-player/pages/player/player?pageId=${encodeURIComponent(page.pageId)}&chapterNumber=${chapterNumber}` + : '') + const audioCueCount = Array.isArray(page.audioCueIds) + ? page.audioCueIds.length + : 0 + const completedEventIds = readerState + ? readerState.completedEventIds + : this.data.completedIds + const eventCompleted = page.type === 'event' + ? completedEventIds.includes(page.eventId) + : false + const activeInteraction = readerState + ? readerState.activeInteraction + : null + const currentInteractionEnabled = Boolean( + page.type === 'event' + && activeInteraction + && activeInteraction.type === 'event' + && activeInteraction.pageId === page.pageId + && activeInteraction.eventId === page.eventId + ) + const sourceReviewEvent = page.type === 'event' + ? (this.data.chapter.events || []).find( + (item) => item.hotspotId === page.eventId, + ) || null + : null + const chapterOneReviewEvent = ( + sourceReviewEvent && this.data.chapterOneComicMode + ) + ? chapterOneEventCopy(sourceReviewEvent) + : sourceReviewEvent + return { + currentPage: { + ...page, + illustrationAssetId: assetId, + audioAvailable: pageAudio.available, + audioSrc: pageAudio.src, + audioCompanionAvailable: Boolean(audioCompanion), + audioCompanionPath, + audioCueCount, + audioPlanned: audioCueCount > 0, + audioDurationLabel: formatTime( + audioCompanion + ? audioCompanion.durationSeconds + : page.audioDurationSeconds, + ), + eventCompleted, + displayCaption: ( + eventCompleted && page.resolvedCaption + ? page.resolvedCaption + : page.caption + ), + }, + currentPageIndex: safeIndex, + currentPageNumber: safeIndex + 1, + currentPageId: page.pageId, + comicImageSrc: imageState.src, + comicImageFallback: imageState.fallback, + comicImageActorFallback: imageState.actorFallback, + comicImageUsingFallback: imageState.usingFallback, + comicImageUsesActorFallback: imageState.usingActorFallback, + comicImageError: imageState.error, + comicImageSource: imageState.source, + comicImageShouldResolveRemote: imageState.shouldResolveRemote, + comicPageImageMode: getComicPageImageMode( + currentPageActor, + imageState.usingActorFallback, + ), + comicPageImageStyle: getComicPageImageStyle( + currentPageActor, + imageState.usingActorFallback, + ), + comicArtStageStyle: getComicArtStageStyle( + this._layoutMetrics, + page, + ), + comicPreviousDisabled: readerState + ? !readerState.canGoPrevious + : safeIndex <= 0, + comicNextDisabled: safeIndex >= model.pageSequence.length - 1, + comicNextLocked: readerState + ? readerState.nextPageLocked + : false, + currentEventCompleted: eventCompleted, + currentInteractionEnabled, + chapterOneReviewEvent, + } + }, + + applyComicReaderState(readerState, persist = true) { + if (!readerState || !readerState.valid) return + const pageData = this.getComicPageData( + readerState.currentPageIndex, + readerState, + ) + if (!pageData.currentPage) return + const pageChanged = Boolean( + this.data.currentPageId + && this.data.currentPageId !== pageData.currentPageId + ) + const nextAudioKey = `page:${pageData.currentPage.pageId}` + if ( + this._audioProgressKey + && this._audioProgressKey.startsWith('page:') + && this._audioProgressKey !== nextAudioKey + ) { + this.destroyAudio() + } + this._readerState = readerState + this._readerProgress = { + currentPageId: readerState.currentPageId, + completedEventIds: [...readerState.completedEventIds], + chapterFinished: readerState.chapterFinished, + } + this.setData({ + ...pageData, + ...pageProgressData(readerState), + chapterFinished: readerState.chapterFinished, + textReadingOpen: false, + ...(pageChanged ? { + chapterOneBackOpen: false, + chapterOneEmotionOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + chapterOneReviewEvent: pageData.chapterOneReviewEvent || null, + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + emotionResult: null, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + } : {}), + }) + if (pageData.comicImageShouldResolveRemote) { + this.resolveComicPageAsset( + pageData.currentPage, + readerState.currentPageIndex, + ) + } + if (persist) { + saveProgress(mergeComicReaderProgress( + getProgress(), + this._comicModel, + this.data.chapterNumber, + this._readerProgress, + )) + } + this.ensureCurrentMemoryCollected(pageData.currentPage) + }, + + applyReaderAction(action, persist = true) { + if (!this._comicModel || !this._readerProgress) { + return this._readerState + } + const nextProgress = applyComicReaderAction( + this._comicModel, + this._readerProgress, + action, + ) + const nextState = buildComicReaderState( + this._comicModel, + nextProgress, + ) + this.applyComicReaderState(nextState, persist) + return nextState + }, + + refreshComicPage() { + if (!this._readerState) return + this.setData(this.getComicPageData( + this._readerState.currentPageIndex, + this._readerState, + )) + }, + + getAssetManager() { + if (!this._assetManager) { + this._assetManager = createAssetManager({ + assetPlatform: createAssetPlatformFacade(), + manifest: releaseAssets, + ...assetReleaseConfig, + }) + } + return this._assetManager + }, + + async resolveComicPageAsset(page, pageIndex) { + const assetId = comicAssetIdForPage(page) + const releaseAsset = assetId ? releaseAssets[assetId] : null + if ( + !releaseAsset + || releaseAsset.kind !== 'image' + || releaseAsset.localSeed + || !releaseAsset.remotePath + ) return + const pageId = page.pageId + try { + const resolved = await this.getAssetManager().resolve(assetId) + if ( + this.data.currentPageId === pageId + && resolved.available + && resolved.uri + ) { + this.setData({ + comicImageSrc: resolved.uri, + comicImageUsingFallback: false, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: resolved.source, + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + } else if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } catch (error) { + if ( + this.data.currentPageId === pageId + && !this.data.comicImageSrc + ) { + this.setData({ + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + }) + } + } + + const model = this._comicModel + if (!model || this.data.currentPageId !== pageId) return + const ahead = Math.max( + 0, + Number(assetReleaseConfig.prefetchAhead) || 0, + ) + const nextAssetIds = model.pageSequence + .slice(Number(pageIndex) + 1, Number(pageIndex) + 1 + ahead) + .map(comicAssetIdForPage) + .filter((nextAssetId) => ( + releaseAssets[nextAssetId] + && !releaseAssets[nextAssetId].localSeed + && releaseAssets[nextAssetId].remotePath + )) + if (nextAssetIds.length) { + this.getAssetManager().prefetch(nextAssetIds).catch(() => {}) + } + }, + + onComicImageLoad() { + if (this.data.comicImageError) { + this.setData({ comicImageError: '' }) + } + }, + + onComicImageError() { + const actorFallback = this.data.comicImageActorFallback + const fallback = this.data.comicImageFallback + const currentActor = pageActor( + this.data.chapter, + this.data.currentPage, + ) + if ( + actorFallback + && this.data.comicImageSrc !== actorFallback + && !this.data.comicImageUsesActorFallback + ) { + this.setData({ + comicImageSrc: actorFallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: true, + comicImageError: '', + comicImageSource: 'actor-fallback', + comicPageImageMode: getComicPageImageMode( + currentActor, + true, + ), + comicPageImageStyle: getComicPageImageStyle( + currentActor, + true, + ), + comicFocusImageStyle: getComicFocusImageStyle( + this.data.activePerson + || pageActor(this.data.chapter, this.data.currentPage), + true, + ), + }) + return + } + if ( + fallback + && this.data.comicImageSrc !== fallback + && ( + !this.data.comicImageUsingFallback + || this.data.comicImageUsesActorFallback + ) + ) { + this.setData({ + comicImageSrc: fallback, + comicImageUsingFallback: true, + comicImageUsesActorFallback: false, + comicImageError: '', + comicImageSource: 'local-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + return + } + this.setData({ + comicImageSrc: '', + comicImageUsesActorFallback: false, + comicImageError: IMAGE_UNAVAILABLE_MESSAGE, + comicImageSource: 'text-fallback', + comicPageImageMode: 'scaleToFill', + comicPageImageStyle: '', + comicFocusImageStyle: '', + }) + }, + + applyLayoutMetrics(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + if (resizeInfo) { + windowInfo = { + ...windowInfo, + windowWidth: resizeInfo.windowWidth || windowInfo.windowWidth, + windowHeight: resizeInfo.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getChapterLayoutMetrics(windowInfo, menuRect) + this._layoutMetrics = metrics + const pageStyle = [ + `height:${metrics.windowHeight}px`, + `--safe-left:${metrics.safeLeft}px`, + `--safe-right:${metrics.safeRight}px`, + `--safe-bottom:${metrics.safeBottom}px`, + `--modal-top:${metrics.modalTop}px`, + `--topbar-height:${metrics.topbarHeight}px`, + ].join(';') + const topbarStyle = [ + `height:${metrics.topbarHeight}px`, + `padding-top:${metrics.topPadding}px`, + `padding-right:${metrics.capsuleReserve}px`, + `padding-bottom:${metrics.bottomPadding}px`, + `padding-left:${metrics.leftInset}px`, + ].join(';') + const nextData = { + compactHeight: metrics.compactHeight, + windowHeight: metrics.windowHeight, + pageStyle, + topbarStyle, + comicArtStageStyle: getComicArtStageStyle( + metrics, + this.data.currentPage, + ), + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + metrics, + this.data.fontScale, + ), + } + this.setData(nextData) + }, + + openCurrentFrontAction() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || !this.data.frontControlsRevealed + ) return + if (page.type === 'event' && this.data.currentInteractionEnabled) { + this.openComicActor() + return + } + if (page.type === 'emotion') { + this.openChapterOneEmotion() + return + } + this.openChapterOneBack() + }, + + revealCurrentFrontControls() { + if ( + !this.data.storyFirstComicMode + || !this.data.currentPage + || this.data.chapterOneBackOpen + || this.data.decisionOpen + || this.data.chapterOneEmotionOpen + || this.data.frontControlsRevealed + ) return + this.setData({ + frontControlsRevealed: true, + frontHintVisible: false, + }) + const settings = getSettings() + if (settings.storyFrontTapHintSeen === true) return + settings.storyFrontTapHintSeen = true + saveSettings(settings) + this._storyFrontTapHintSeen = true + }, + + openComicActor() { + const page = this.data.currentPage + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !page + || page.type !== 'event' + || !interaction + || interaction.type !== 'event' + || interaction.pageId !== page.pageId + || interaction.eventId !== page.eventId + ) return + if (this.data.storyFirstComicMode) { + if (!this.data.frontControlsRevealed) return + this.openChapterOneBack() + return + } + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.openDecision(page.eventId, person, { question: page.question }) + }, + + openChapterOneBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !page + || page.type === 'emotion' + || !this.data.frontControlsRevealed + ) return + this.setData({ chapterOneBackOpen: true }) + }, + + startChapterOneDecisionFromBack() { + const page = this.data.currentPage + if ( + !this.data.storyFirstComicMode + || !this.data.chapterOneBackOpen + || !page + || page.type !== 'event' + || this.data.currentEventCompleted + ) return + const person = this.data.chapter.people.find( + (item) => item.instanceId === page.actorInstanceId, + ) + if (!person) return + this.setData({ chapterOneBackOpen: false }) + this.openDecision(page.eventId, person, { question: page.question }) + }, + + closeChapterOneBack() { + this.setData({ + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + nextChapterOnePageFromBack() { + this.setData({ chapterOneBackOpen: false }) + this.nextComicPage() + }, + + openChapterOneEmotion() { + const page = this.data.currentPage + const readerState = this._readerState + const interaction = readerState ? readerState.activeInteraction : null + if ( + !this.data.storyFirstComicMode + || !page + || page.type !== 'emotion' + || !this.data.frontControlsRevealed + ) return + if ( + interaction + && interaction.type === 'emotion' + && interaction.pageId === page.pageId + ) { + this.setData({ chapterOneEmotionOpen: true }) + return + } + // 已完成章节回看 P07 时,画面仍应是一条真实可走的翻页路径。 + // 不能继续绑定一个已经没有 activeInteraction 的空操作。 + if (readerState && readerState.chapterFinished && readerState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + closeChapterOneEmotion() { + this.setData({ + chapterOneEmotionOpen: false, + emotionResult: null, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + openTextReading() { + if (!this.data.currentPage) return + this.setData({ textReadingOpen: true }) + }, + + closeTextReading() { + this.setData({ textReadingOpen: false }) + }, + + openDecision(eventId, person, displayOverrides = {}) { + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'event' + || interaction.eventId !== eventId + || interaction.pageId !== this.data.currentPageId + ) return + const sourceEvent = this.data.chapter.events.find( + (item) => item.hotspotId === eventId, + ) + if (!sourceEvent) return + const activeEvent = { + ...(this.data.chapterOneComicMode + ? chapterOneEventCopy(sourceEvent) + : sourceEvent), + ...displayOverrides, + ...(this.data.storyFirstComicMode + ? { + question: storyFirstQuestion( + sourceEvent, + displayOverrides.question, + ), + } + : {}), + choiceOptions: this.data.storyFirstComicMode + ? storyFirstChoiceOptions( + sourceEvent.hotspotId, + sourceEvent.options, + ) + : [], + } + this.setData({ + activeEvent, + activePerson: person, + decisionOpen: true, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: getComicFocusImageStyle( + person, + this.data.comicImageUsesActorFallback, + ), + }) + }, + + answer(event) { + const value = event.currentTarget.dataset.value + const activeEvent = this.data.activeEvent + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !activeEvent + || this.data.resultMode + || !interaction + || interaction.type !== 'event' + || interaction.eventId !== activeEvent.hotspotId + || interaction.pageId !== this.data.currentPageId + ) return + + if (value !== activeEvent.correctAnswer) { + this.setData({ + resultMode: 'retry', + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'retry', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + return + } + + const needsSafetyDetail = activeEvent.hotspotId === 'S01-H36' + if (!needsSafetyDetail && !this.data.storyFirstComicMode) { + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + resultMode: 'correct', + resultDetailOpen: false, + decisionResultNeedsScroll: needsSafetyDetail + ? false + : decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + wx.vibrateShort({ type: 'light' }) + }, + + retryAnswer() { + this.setData({ + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + }) + }, + + openResultDetail() { + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode !== 'correct' + || !activeEvent + || activeEvent.hotspotId !== 'S01-H36' + ) return + this.setData({ + resultDetailOpen: true, + decisionResultNeedsScroll: decisionResultNeedsScroll( + activeEvent, + 'correct', + this._layoutMetrics, + this.data.fontScale, + ), + }) + }, + + continueAfterDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + const activeEvent = this.data.activeEvent + if ( + this.data.resultMode === 'correct' + && activeEvent + && activeEvent.hotspotId === 'S01-H36' + ) { + if (!this.data.resultDetailOpen) { + this.openResultDetail() + return + } + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + }) + this.refreshComicPage() + }, + + closeDecision() { + if ( + this.data.storyFirstComicMode + && this.data.resultMode === 'correct' + ) { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.finishChapterOneDecision(false) + return + } + if (this.data.resultMode === 'correct') { + if ( + this.data.activeEvent + && this.data.activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) { + this.openResultDetail() + return + } + this.continueAfterDecision() + return + } + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + }, + + finishChapterOneDecision(goNext) { + const activeEvent = this.data.activeEvent + if ( + !this.data.storyFirstComicMode + || this.data.resultMode !== 'correct' + || !activeEvent + || ( + activeEvent.hotspotId === 'S01-H36' + && !this.data.resultDetailOpen + ) + ) return + const nextState = this.applyReaderAction({ + type: 'complete-event', + eventId: activeEvent.hotspotId, + }) + if (!nextState.completedEventIds.includes(activeEvent.hotspotId)) return + this.setData({ + decisionOpen: false, + activeEvent: null, + activePerson: null, + resultMode: '', + resultDetailOpen: false, + decisionResultNeedsScroll: false, + comicFocusImageStyle: '', + chapterOneBackOpen: false, + frontControlsRevealed: false, + frontHintVisible: false, + }) + if (goNext) this.applyReaderAction({ type: 'next-page' }) + else this.refreshComicPage() + }, + + returnFromChapterOneDecision() { + this.finishChapterOneDecision(false) + }, + + nextFromChapterOneDecision() { + this.finishChapterOneDecision(true) + }, + + chooseEmotion(event) { + const choiceId = event.currentTarget.dataset.choice + const moment = this.data.chapter.emotionMoment + const interaction = this._readerState + ? this._readerState.activeInteraction + : null + if ( + !interaction + || interaction.type !== 'emotion' + || interaction.emotionMomentId !== moment.emotionMomentId + || interaction.pageId !== this.data.currentPageId + ) return + const choice = moment.playerChoices.find((item) => item.choiceId === choiceId) + if (!choice) return + this.setData({ + emotionResult: { + feedback: choice.characterFeedback, + echo: moment.tableEcho, + category: moment.echoCategory, + }, + }) + }, + + finishChapterOneEmotionAndTurn() { + const moment = this.data.chapter && this.data.chapter.emotionMoment + if ( + !this.data.storyFirstComicMode + || !moment + || !this.data.emotionResult + ) return + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + if (nextState.canGoNext) { + this.applyReaderAction({ type: 'next-page' }) + } + }, + + finishEmotion() { + const moment = this.data.chapter.emotionMoment + const nextState = this.applyReaderAction({ + type: 'complete-emotion', + emotionMomentId: moment.emotionMomentId, + }) + if (!nextState || !nextState.chapterFinished) return + // Completion unlocks P08 but deliberately leaves the reader on P07. The + // memory card opens only after the user's next explicit tap or page turn. + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + finishComicEmotion() { + if (!this.data.emotionResult && !this.data.chapterFinished) { + wx.showToast({ title: '先选一种陪伴方式', icon: 'none' }) + return + } + if (this.data.chapterFinished) { + this.applyReaderAction({ type: 'next-page' }) + return + } + this.finishEmotion() + }, + + previousComicPage() { + this.applyReaderAction({ type: 'previous-page' }) + }, + + nextComicPage() { + const page = this.data.currentPage + if (!page) return + if (this._readerState && this._readerState.nextPageLocked) { + if (page.type === 'event') { + wx.showToast({ + title: page.playableVisual.kind === 'evidence-composite' + ? '先看看桌上的东西' + : '先看看画里发生了什么', + icon: 'none', + }) + return + } + if (page.type === 'emotion') { + wx.showToast({ title: '先收下这一桌的回声', icon: 'none' }) + return + } + } + if ( + !this._readerState + || !this._readerState.canGoNext + ) { + return + } + this.applyReaderAction({ type: 'next-page' }) + }, + + onComicTouchStart(event) { + const touch = event && event.touches && event.touches[0] + if (!touch) return + this._comicTouchStart = { + x: Number(touch.clientX) || 0, + y: Number(touch.clientY) || 0, + at: Date.now(), + } + }, + + onComicTouchEnd(event) { + const start = this._comicTouchStart + this._comicTouchStart = null + const touch = event && event.changedTouches && event.changedTouches[0] + if (!start || !touch) return + const deltaX = (Number(touch.clientX) || 0) - start.x + const deltaY = (Number(touch.clientY) || 0) - start.y + const elapsed = Date.now() - start.at + if ( + elapsed > 900 + || Math.abs(deltaX) < 64 + || Math.abs(deltaX) < Math.abs(deltaY) * 1.35 + ) return + if (deltaX > 0) this.previousComicPage() + else this.nextComicPage() + }, + + collectMemoryCard() { + const page = this.data.currentPage + const card = page && page.memoryCard + if (!card || !card.cardId) return + const chapter = this.data.chapter || {} + const progress = addMemoryCard(getProgress(), card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '已收进封面的桂香岁月', + icon: 'none', + }) + } + if (typeof wx.vibrateShort === 'function') { + wx.vibrateShort({ type: 'light' }) + } + }, + + ensureCurrentMemoryCollected(page) { + const card = page && page.type === 'memory' && page.memoryCard + if (!card || !card.cardId) return false + const existing = getProgress() + const collected = Array.isArray(existing.collectedMemoryCards) + ? existing.collectedMemoryCards + : [] + const snapshots = ( + existing.memoryCardSnapshots + && typeof existing.memoryCardSnapshots === 'object' + && !Array.isArray(existing.memoryCardSnapshots) + ) + ? existing.memoryCardSnapshots + : {} + const alreadyComplete = ( + collected.includes(card.cardId) + && snapshots[card.cardId] + && snapshots[card.cardId].cardId === card.cardId + ) + if (alreadyComplete) { + if (!this.data.memoryCollected) { + this.setData({ memoryCollected: true }) + } + return false + } + const chapter = this.data.chapter || {} + const progress = addMemoryCard(existing, card, { + chapterId: chapter.chapterId, + chapterNumber: this.data.chapterNumber || chapter.chapterNumber, + chapterTitle: chapter.title, + }) + saveProgress(progress) + this.setData({ memoryCollected: true }) + return true + }, + + openLifeReport() { + const page = this.data.currentPage + const card = page && page.type === 'memory' && page.memoryCard + const url = lifeReportPath(card) + if (!url) return + wx.navigateTo({ url }) + }, + + toggleMemoryFamily() { + this.setData({ memoryFamilyOpen: !this.data.memoryFamilyOpen }) + }, + + toggleComicPageAudio() { + const page = this.data.currentPage + if (page && page.audioCompanionAvailable && page.audioCompanionPath) { + if (this._audioCompanionNavigationLocked) return + this._audioCompanionNavigationLocked = true + this.setData({ audioLoading: true, audioError: '' }) + wx.navigateTo({ + url: page.audioCompanionPath, + fail: () => { + this._audioCompanionNavigationLocked = false + this.setData({ + audioLoading: false, + audioError: '有声夹页暂时没有打开,请稍后再试。', + }) + if (typeof wx.showToast === 'function') { + wx.showToast({ + title: '有声夹页没打开,请稍后再试', + icon: 'none', + }) + } + }, + }) + return + } + if (!page || !page.audioAvailable || !page.audioSrc) return + this.toggleAudioSource( + page.audioSrc, + `page:${page.pageId}`, + Number(page.audioDurationSeconds) || 0, + { + restartFromBeginning: true, + resumeSavedProgress: false, + persistProgress: false, + }, + ) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ + fontScale, + decisionResultNeedsScroll: decisionResultNeedsScroll( + this.data.activeEvent, + this.data.resultMode, + this._layoutMetrics, + fontScale, + ), + }) + }, + + toggleAudio() { + const chapter = this.data.chapter + if ( + !chapter + || !this.data.chapterAudioAvailable + || !this.data.chapterAudioSrc + ) return + this.toggleAudioSource( + this.data.chapterAudioSrc, + chapter.chapterId, + Number(chapter.audio.durationSeconds) || 0, + ) + }, + + toggleAudioSource(src, progressKey, durationSeconds, options = {}) { + if (!src || !progressKey) return + if ( + this.audioContext + && ( + this._audioProgressKey !== progressKey + || this._audioSrc !== src + ) + ) { + this.destroyAudio() + } + if (!this.audioContext) { + this.createAudioContext({ + src, + progressKey, + durationSeconds, + resumeSavedProgress: options.resumeSavedProgress, + persistProgress: options.persistProgress, + }) + this.setData({ + audioCurrent: '0:00', + audioDuration: formatTime(durationSeconds), + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + if (this.data.audioPlaying) { + this.audioContext.pause() + return + } + if ( + options.restartFromBeginning + && this.audioContext + && Number(this.data.audioPercent) > 0 + ) { + this.audioContext.seek(0) + this.setData({ + audioCurrent: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + } + this.setData({ audioLoading: true, audioError: '' }) + this.audioContext.play() + }, + + createAudioContext(options = {}) { + const src = String(options.src || '') + const progressKey = String(options.progressKey || '') + const durationSeconds = Math.max( + 0, + Number(options.durationSeconds) || 0, + ) + if (!src || !progressKey) return + const context = wx.createInnerAudioContext() + context.autoplay = false + context.src = src + this._audioProgressKey = progressKey + this._audioSrc = src + this._audioDurationSeconds = durationSeconds + this._audioResumeSavedProgress = options.resumeSavedProgress !== false + this._audioPersistProgress = options.persistProgress !== false + const isCurrentContext = () => this.audioContext === context + + context.onCanplay(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + const resumeAt = Number(saved[progressKey] || 0) + if ( + this._audioResumeSavedProgress + && + resumeAt > 1 + && durationSeconds > 2 + && resumeAt < durationSeconds - 2 + ) { + context.seek(resumeAt) + } + this.setData({ audioLoading: false, audioError: '' }) + }) + context.onPlay(() => { + if (!isCurrentContext()) { + context.pause() + return + } + if (this._audioPageHidden) { + context.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + return + } + this.setData({ audioPlaying: true, audioLoading: false, audioError: '' }) + }) + context.onPause(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onStop(() => { + if (!isCurrentContext()) return + this.setData({ audioPlaying: false, audioLoading: false }) + this.saveCurrentAudioTime() + }) + context.onEnded(() => { + if (!isCurrentContext()) return + const saved = getAudioProgress() + if (this._audioPersistProgress) saved[progressKey] = 0 + else delete saved[progressKey] + saveAudioProgress(saved) + const completedDuration = context.duration || durationSeconds + this.setData({ + audioPlaying: false, + audioLoading: false, + audioCurrent: formatTime(completedDuration), + audioDuration: formatTime(completedDuration), + audioPercent: 100, + audioProgressStyle: 'width:100%', + }) + }) + context.onTimeUpdate(() => { + if (!isCurrentContext()) return + const duration = context.duration || durationSeconds + const current = context.currentTime || 0 + this.setData({ + audioCurrent: formatTime(current), + audioDuration: formatTime(duration), + audioPercent: duration > 0 ? Math.min(100, current / duration * 100) : 0, + audioProgressStyle: `width:${ + duration > 0 ? Math.min(100, current / duration * 100) : 0 + }%`, + }) + }) + context.onError(() => { + if (!isCurrentContext()) return + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '音频暂时没有打开,文字游戏仍可继续。', + }) + }) + this.audioContext = context + }, + + saveCurrentAudioTime() { + if ( + !this.audioContext + || !this._audioProgressKey + || !this._audioPersistProgress + ) return + const saved = getAudioProgress() + saved[this._audioProgressKey] = this.audioContext.currentTime || 0 + saveAudioProgress(saved) + }, + + pauseAudio(force = false) { + if (!this.audioContext) return + if (force || this.data.audioPlaying || this.data.audioLoading) { + this.audioContext.pause() + this.setData({ + audioPlaying: false, + audioLoading: false, + }) + } + }, + + destroyAudio() { + if (!this.audioContext) return + this.saveCurrentAudioTime() + this.audioContext.destroy() + this.audioContext = null + this._audioProgressKey = '' + this._audioSrc = '' + this._audioDurationSeconds = 0 + this._audioResumeSavedProgress = true + this._audioPersistProgress = true + this.setData({ + audioPlaying: false, + audioLoading: false, + audioError: '', + audioCurrent: '0:00', + audioDuration: '0:00', + audioPercent: 0, + audioProgressStyle: 'width:0%', + }) + }, + + previousChapter() { + if (this.data.chapterNumber <= 1) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber - 1), + }) + }, + + nextChapter() { + if (this.data.chapterNumber >= this.data.chapterCount) return + this.destroyAudio() + wx.redirectTo({ + url: chapterRoute(this.data.chapterNumber + 1), + }) + }, + + goCatalog() { + this.destroyAudio() + const pages = getCurrentPages() + const previous = pages[pages.length - 2] + if (previous && previous.route === 'pages/catalog/catalog') { + wx.navigateBack() + return + } + wx.redirectTo({ url: '/pages/catalog/catalog' }) + }, + + onShareAppMessage(options = {}) { + const memoryCard = ( + options.from === 'button' + && this.data.currentPage + && this.data.currentPage.type === 'memory' + ) + ? this.data.currentPage.memoryCard + : null + const sharePayload = { + title: memoryCard && memoryCard.familyLine + ? `带回家的一句话:${memoryCard.familyLine}` + : `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + path: memoryCardSharePath(memoryCard) + || chapterRoute(this.data.chapterNumber), + } + const localPath = String(this.data.sharePreviewLocalPath || '').trim() + if (localPath) { + return { + ...sharePayload, + imageUrl: localPath, + } + } + return { + ...sharePayload, + promise: this.prepareSharePreview().then((resolvedPath) => ( + resolvedPath + ? { ...sharePayload, imageUrl: resolvedPath } + : sharePayload + )), + } + }, + + onShareTimeline() { + return { + title: `唐侦探·第${this.data.chapterNumber}回:${this.data.chapter.title}`, + query: `chapter=${this.data.chapterNumber}`, + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapter.json b/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapter.json new file mode 100644 index 0000000..8f8cc68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapter.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapter.wxml b/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapter.wxml new file mode 100644 index 0000000..a5d5a3d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapter.wxml @@ -0,0 +1,932 @@ + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}} + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + + {{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}} + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边回声 + {{currentPage.memoryCard.tableEcho}} + + + 带回生活里 + {{currentPage.memoryCard.lifeAction}} + + “{{currentPage.memoryCard.familyLine}}” + + + + + + + + {{currentPage.actorName}} · 刚才看见的 + {{currentPage.headline}} + {{chapterOneReviewEvent.actionDescription}} + + 您刚才瞧见的 + {{chapterOneReviewEvent.evidence}} + + + 下回遇到这事 + {{chapterOneReviewEvent.actionAdvice}} + + + + + {{currentPage.actorName}} · 这回事 + {{currentPage.headline}} + {{currentPage.caption}} + + 桌边一句话 + “{{currentPage.shortDialogue}}” + + + + + {{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}} + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边回声 + + + + 没有标准答案 + {{currentPage.headline}} + {{emotionResult.feedback}} + + 这一桌记住了 + {{emotionResult.echo}} + + + + + + + + + + + + + + + {{currentPage.sceneAlt}} + 桌边一句话 + + + + + 这一刻,您想怎么说? + {{currentPage.prompt}} + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + + + + + + + + + + 轻点画面,按钮就出来了 + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 听听这一桌心里话 + {{currentPage.headline}} + {{currentPage.caption}} + + {{currentPage.prompt}} + 没有标准答案,选您更愿意说的话。 + + + + 桌边回声 + {{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}} + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + {{currentPage.detailInset.label}} + + + + + 第一季 · 季终 + {{currentPage.seasonCloseCaption}} + + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}} + {{currentPage.memoryCard.tableEcho}} + + 今天可以这样做 + {{currentPage.memoryCard.lifeAction}} + + + 带回家聊一聊 + “{{currentPage.memoryCard.familyLine}}” + + + {{currentPage.seasonCloseTail}} + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + + + + 下一回的门缝{{currentPage.caption}} + 桂香记忆卡 + {{currentPage.memoryCard.eraLine}}{{currentPage.memoryCard.characterName}} + + {{item}} + + + {{currentPage.memoryCard.tableEcho}} + 今天可以这样做{{currentPage.memoryCard.lifeAction}} + + 带回家聊一聊“{{currentPage.memoryCard.familyLine}}” + + + + + + + + + + + + + + + + + + + + + + {{comicImageError}} + {{currentPage.sceneAlt}} + + + + 手边证据 + {{currentPage.playableVisual.evidenceLabel}} + {{currentPage.playableVisual.evidenceDetail}} + + + + + + + + + + + {{chapter.year}} + {{currentPage.headline}} + + {{currentPage.displayCaption}} + + + {{currentPage.headline}} + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + + + {{currentPage.displayCaption}} + {{currentPage.secondaryCaption}} + + + 再看看{{currentPage.actorName}}的手边 + + + 看见了 + 可以翻到下一页。 + + + + + + + 再看看{{currentPage.actorName}}的手边 + + + + + + {{currentPageNumber}}/{{comicPageCount}} + + + + + + + + + + + + 第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页 + + + + {{currentPage.headline}} + {{currentPage.displayCaption}} + {{currentPage.caption}} + {{currentPage.secondaryCaption}} + + 这一页想和您聊 + {{currentPage.prompt}} + + + 看看画里发生了什么 + {{currentPage.question}} + + + + + + + + + + + + {{currentPage.sceneAlt}} + 画里的线索 + + + + + 碰上这事,您怎么做? + {{activeEvent.question}} + + + + + + + + + + + {{currentPage.sceneAlt}} + 再看看画里 + + + + 别着急,再瞧瞧画里。 + {{activeEvent.retryHint}} + + + + + + + + {{currentPage.sceneAlt}} + 这下有眉目了 + + + + {{activeEvent.actorName}} · 这一页背面 + {{currentPage.headline}} + {{activeEvent.actionDescription}} + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + + 您刚才瞧见的 + {{activeEvent.evidence}} + + {{activeEvent.reason}} + + 下回遇到这事 + {{activeEvent.actionAdvice}} + + + + + 上划接着看 ↑ + + + + + + + + + + + + + + + + + + + {{activePerson.eraLabel}} · 画中人物 + {{activeEvent.actorName}} + + + + + {{activeEvent.actionDescription}} + + + + {{activeEvent.evidence}} + + + + + {{activeEvent.speech}} + + + + + + + + 故事走到这里,您想怎么做? + {{activeEvent.question}} + + + + + + + + 没关系,再看看画里的细节。 + {{activeEvent.retryHint}} + + + + 这处线索对上了。 + + 先把眼前这一步做好 + 先停酒、移开危险物,并留下人陪伴。 + + 这些表现不能仅凭外观确定原因,先保证安全。 + + 现在可以怎么做 + {{activeEvent.actionAdvice}} + + {{activeEvent.reason}} + + + + + + + + + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapter.wxss b/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapter.wxss new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapter.wxss @@ -0,0 +1,3883 @@ +.chapter-page { + --reader-control-min: 48px; + position: relative; + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; + flex-direction: column; + color: var(--ink); + background: #211812; +} + +.dialog-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + var(--modal-top) + max(12rpx, var(--safe-right)) + max(12rpx, var(--safe-bottom)) + max(12rpx, var(--safe-left)); + background: rgba(18, 12, 8, 0.82); +} + +.decision-dialog { + position: relative; + display: grid; + width: min(1420rpx, 95vw); + height: min(760rpx, 89vh); + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.dialog-close { + position: absolute; + z-index: 6; + top: 14rpx; + right: 16rpx; + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + color: #4d392a; + background: #f2e3bd; + border: 2rpx solid #9d825d; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 34px; +} + +.character-story { + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + padding: 0; + color: #f2e6c7; + background: + radial-gradient(circle at 12% 16%, rgba(181, 57, 44, 0.2), transparent 35%), + #293844; + border-right: 7rpx solid #822a23; +} + +.character-story-inner { + box-sizing: border-box; + min-height: 100%; + padding: 30rpx 34rpx 48rpx; +} + +.story-label { + display: block; + margin-top: 20rpx; + color: #e8c477; + font-size: 21rpx; + font-weight: 850; +} + +.action-description { + display: block; + margin-top: 7rpx; + font-size: 29rpx; + font-weight: 750; + line-height: 1.55; +} + +.speech-box, +.evidence-box { + display: flex; + margin-top: 16rpx; + padding: 16rpx 18rpx; + border-left: 7rpx solid #d0a654; + flex-direction: column; + font-size: 24rpx; + line-height: 1.58; +} + +.speech-box { + background: rgba(246, 225, 180, 0.11); +} + +.evidence-box { + background: rgba(122, 164, 139, 0.14); + border-left-color: #76a286; +} + +.speech-box .story-label, +.evidence-box .story-label { + margin-top: 0; +} + +.font-xlarge .action-description { + font-size: 33rpx; +} + +.font-xlarge .speech-box, +.font-xlarge .evidence-box { + font-size: 27rpx; +} + +.judgement-side { + display: grid; + min-width: 0; + min-height: 0; + max-height: 100%; + height: 100%; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f2e5bd; +} + +.judgement-scroll { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: auto; + max-height: 100%; + padding: 0; +} + +.judgement-scroll-inner { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 100%; + padding: 18px 20px 14px; + flex-direction: column; +} + +.judgement-scroll-inner.is-choice { + justify-content: center; +} + +.judgement-scroll-inner.is-result { + padding: 12px; +} + +.judgement-scroll-inner.is-result .result-box { + min-height: 100%; + flex: 1 0 auto; + justify-content: center; +} + +.judgement-lead { + display: block; + padding-right: 48px; + color: #6b5138; + font-size: 17px; + font-weight: 850; + line-height: 1.35; +} + +.judgement-question { + display: block; + margin-top: 7px; + font-size: 28px; + font-weight: 900; + line-height: 1.3; +} + +.font-xlarge .judgement-question { + font-size: 31px; +} + +.font-xlarge .judgement-title { + font-size: 22px; +} + +.font-xlarge .judgement-subtitle { + font-size: 17px; +} + +.font-xlarge .result-box { + font-size: 20px; +} + +.font-xlarge .reason { + font-size: 21px; +} + +.font-xlarge .advice-label { + font-size: 17px; +} + +.judgement-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.judgement-button { + display: grid; + min-height: 72px; + grid-template-columns: 50px 1fr; + align-items: center; + gap: 11px; + padding: 9px 12px; + color: #3f3328; + background: #f4e8c5; + border: 3px solid #8a7355; + border-radius: 10px; + text-align: left; +} + +.judgement-button.observe, +.judgement-button.continue { + color: #3f3328; + background: #f4e8c5; + border-color: #8a7355; +} + +.judgement-icon { + display: flex; + width: 46px; + height: 46px; + align-items: center; + justify-content: center; + color: #5e4934; + background: #e8d6ab; + border: 3px solid #8a7355; + border-radius: 50%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 29px; + font-weight: 900; +} + +.judgement-button-copy { + display: flex; + flex-direction: column; +} + +.judgement-title { + font-size: 20px; + font-weight: 900; +} + +.judgement-subtitle { + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; +} + +.result-box { + display: flex; + margin-top: 14px; + padding: 14px; + border: 2px solid; + border-radius: 9px; + flex-direction: column; + font-size: 18px; + line-height: 1.5; +} + +.result-box.retry, +.result-box.correct { + color: #43372b; + background: #efe2bd; + border-color: #947a59; +} + +.result-title { + margin-bottom: 5px; + font-size: 22px; + font-weight: 900; +} + +.reason { + display: block; + margin-top: 9px; + font-size: 19px; + font-weight: 750; +} + +.action-advice { + display: flex; + margin-top: 4px; + padding: 10px; + color: #4c3f31; + background: rgba(255, 251, 235, 0.72); + border-left: 5px solid var(--cinnabar); + flex-direction: column; +} + +.advice-label { + color: #8b3028; + font-size: 16px; + font-weight: 850; +} + +.decision-footer { + position: relative; + z-index: 8; + display: flex; + flex: none; + min-height: 66px; + align-items: center; + padding: 8px 14px max(8px, var(--safe-bottom)); + background: #ead9af; + border-top: 2px solid rgba(126, 91, 52, 0.34); +} + +.decision-footer-button { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + color: #f8e8c5; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 20px; + font-weight: 900; +} + +.decision-footer-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.decision-footer-button.retry { + color: #f5e7c9; + background: #5a4634; + border-color: #80694d; +} + +.emotion-prompt { + margin-top: 18rpx; + color: #7f2a23; + font-size: 34rpx; + font-weight: 900; +} + +.emotion-choices { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 20rpx; +} + +.emotion-choice { + display: grid; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 138rpx; + grid-template-columns: 52rpx 1fr; + align-items: start; + gap: 14rpx; + padding: 20rpx; + color: #3e4f43; + background: #dfe8d9; + border: 3rpx solid #719078; + border-radius: 12rpx; + font-size: 25rpx; + line-height: 1.5; + text-align: left; +} + +.emotion-choice > text:last-child { + min-width: 0; + word-break: break-word; +} + +.choice-number { + display: flex; + width: 48rpx; + height: 48rpx; + align-items: center; + justify-content: center; + color: #f4e8c8; + background: var(--jade); + border-radius: 50%; + font-family: Georgia, serif; + font-size: 25rpx; +} + +.emotion-note { + margin-top: 16rpx; + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + text-align: center; +} + +.feedback { + margin-top: 26rpx; + color: #3f503f; + font-size: 31rpx; + font-weight: 750; + line-height: 1.62; +} + +.table-echo { + display: flex; + margin: 22rpx 0; + padding: 20rpx 24rpx; + color: #f4e5bf; + background: #3c2b20; + border-left: 9rpx solid var(--cinnabar); + flex-direction: column; + font-size: 30rpx; + font-weight: 780; + line-height: 1.55; +} + +.echo-label { + margin-bottom: 5rpx; + color: #e7bd70; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19rpx; + letter-spacing: 4rpx; +} + +/* 横屏手机的 rpx 会按屏幕宽度放大。短高屏的纵向尺寸必须使用 px。 */ +@media (max-height: 620px) { +.compact-height .dialog-backdrop { + align-items: stretch; + justify-content: stretch; + padding: + var(--modal-top) + max(8px, var(--safe-right)) + max(8px, var(--safe-bottom)) + max(8px, var(--safe-left)); +} + +.compact-height .decision-dialog { + width: 100%; + height: 100%; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + border-radius: 7px; +} + +.compact-height .dialog-close { + top: 6px; + right: calc(40% + 6px); + width: 48px; + height: 48px; + border-width: 1px; + font-size: 30px; +} + +.compact-height .character-story { + padding: 0; + border-right-width: 4px; +} + +.compact-height .character-story-inner { + min-height: 100%; + padding: 12px 16px 30px; +} + +.compact-height .story-label { + margin-top: 12px; + font-size: 15px; +} + +.compact-height .action-description { + margin-top: 4px; + font-size: 20px; + line-height: 1.4; +} + +.compact-height .speech-box, +.compact-height .evidence-box { + margin-top: 10px; + padding: 10px 12px; + border-left-width: 4px; + font-size: 16px; + line-height: 1.42; +} + +.compact-height.font-xlarge .action-description { + font-size: 22px; +} + +.compact-height.font-xlarge .speech-box, +.compact-height.font-xlarge .evidence-box { + font-size: 18px; +} + +.compact-height .judgement-side { + padding: 0; +} + +.compact-height .judgement-scroll { + box-sizing: border-box; + padding: 0; +} + +.compact-height .judgement-scroll-inner { + padding: 10px 12px; +} + +.compact-height .judgement-scroll-inner.is-result { + padding: 8px 10px; +} + +.compact-height .judgement-lead { + display: none; +} + +.compact-height .judgement-question { + margin-top: 0; + font-size: 21px; + line-height: 1.18; +} + +.compact-height.font-xlarge .judgement-question { + font-size: 23px; +} + +.compact-height .judgement-options { + gap: 6px; + margin-top: 8px; +} + +.compact-height .judgement-button { + min-height: 58px; + grid-template-columns: 38px 1fr; + gap: 8px; + padding: 5px 8px; + border-width: 3px; + border-radius: 9px; +} + +.compact-height .judgement-icon { + width: 38px; + height: 38px; + border-width: 2px; + font-size: 25px; +} + +.compact-height .judgement-title { + font-size: 18px; + line-height: 1.15; +} + +.compact-height.font-xlarge .judgement-title { + font-size: 20px; +} + +.compact-height .judgement-subtitle { + font-size: 14px; + line-height: 1.2; +} + +.compact-height.font-xlarge .judgement-subtitle { + font-size: 15px; +} + +.compact-height .result-box { + margin-top: 0; + padding: 13px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.45; +} + +.compact-height .result-box.retry { + margin-top: 0; + padding: 10px 12px; + line-height: 1.35; +} + +.compact-height .result-box.retry .result-title { + margin-bottom: 4px; +} + +.compact-height .result-title { + font-size: 22px; +} + +.compact-height .reason { + font-size: 19px; +} + +.compact-height.font-xlarge .result-box { + font-size: 18px; +} + +.compact-height.font-xlarge .reason { + font-size: 20px; +} + +.compact-height .action-advice { + margin-top: 4px; + padding: 10px; + border-left-width: 4px; +} + +.compact-height .advice-label { + font-size: 15px; +} + +.compact-height.font-xlarge .advice-label { + font-size: 16px; +} + +.compact-height .emotion-prompt { + margin-top: 10px; + font-size: 23px; +} + +.compact-height .emotion-choices { + gap: 10px; + margin-top: 12px; +} + +.compact-height .emotion-choice { + min-height: 88px; + grid-template-columns: 34px 1fr; + gap: 9px; + padding: 12px; + border-width: 2px; + border-radius: 8px; + font-size: 17px; + line-height: 1.4; +} + +.compact-height .choice-number { + width: 32px; + height: 32px; + font-size: 17px; +} + +.compact-height .emotion-note { + margin-top: 9px; + font-size: 14px; +} + +.compact-height .feedback { + margin-top: 14px; + font-size: 21px; + line-height: 1.46; +} + +.compact-height .table-echo { + margin: 13px 0; + padding: 12px 15px; + border-left-width: 5px; + font-size: 20px; + line-height: 1.43; +} + +.compact-height .echo-label { + font-size: 14px; + letter-spacing: 2px; +} +} + +/* V3.5 连环画样板:主阅读页只有画、画下文字和翻页。 */ +.comic-topbar { + position: relative; + z-index: 20; + display: flex; + flex: none; + width: 100%; + align-items: center; + gap: 14px; + color: #ead9b5; + background: #2b1f18; + border-bottom: 3px solid #8f2c25; +} + +.comic-catalog-button, +.comic-font-button, +.comic-top-previous-button, +.c1-front-action-button { + display: flex; + min-width: 92px; + min-height: 48px; + flex: none; + align-items: center; + justify-content: center; + padding: 0 15px; + color: #f1dfb9; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.comic-catalog-button { + width: 92px; +} + +.comic-font-button { + width: 104px; + min-width: 104px; + margin-left: auto; +} + +.comic-top-previous-button { + width: 48px; + min-width: 48px; + padding: 0; + font-size: 30px; + line-height: 1; +} + +.comic-top-previous-button.is-disabled { + visibility: hidden; + pointer-events: none; +} + +.c1-front-action-button { + width: 142px; + min-width: 142px; + margin-left: auto; + padding: 0 12px; + color: #fff0c8; + background: #873029; + border-color: #b96556; + font-size: 17px; +} + +.comic-top-previous-button::after, +.c1-front-action-button::after { + border: 0; +} + +.comic-page-audio-slot { + display: grid; + box-sizing: border-box; + width: 142px; + min-width: 142px; + height: 48px; + min-height: 48px; + flex: none; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + color: #f3dfb7; + background: #3c2c22; + border: 1px solid #8b704e; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-page-audio-slot::after { + border: 0; +} + +.comic-page-audio-slot.is-text-mode { + opacity: 1; + color: #ddcca9; + background: #382a22; + border-color: #756147; +} + +.text-reading-backdrop { + z-index: 80; + align-items: center; + justify-content: center; +} + +.text-reading-dialog { + display: grid; + box-sizing: border-box; + width: min(760px, calc(100vw - var(--safe-left) - var(--safe-right) - 28px)); + height: min(520px, calc(100vh - var(--modal-top) - var(--safe-bottom) - 24px)); + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + padding: 22px 26px 20px; + color: #2e241d; + background: #f2e3bd; + border: 3px solid #8c372d; + border-radius: 10px; + box-shadow: 0 16px 42px rgba(15, 9, 5, 0.45); +} + +.text-reading-kicker { + padding-right: 58px; + color: #8e3027; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.text-reading-scroll { + min-height: 0; + border-top: 1px solid rgba(126, 79, 43, 0.35); + border-bottom: 1px solid rgba(126, 79, 43, 0.35); +} + +.text-reading-sheet { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 4px 22px; +} + +.text-reading-title { + color: #752820; + font-size: 30px; + font-weight: 900; + line-height: 1.28; +} + +.text-reading-body { + font-size: 28px; + font-weight: 750; + line-height: 1.55; +} + +.font-xlarge .text-reading-title { + font-size: 34px; +} + +.font-xlarge .text-reading-body { + font-size: 31px; +} + +.font-xlarge .text-reading-secondary, +.font-xlarge .text-reading-prompt { + font-size: 25px; +} + +.text-reading-secondary { + color: #5f4a3d; + font-size: 22px; + line-height: 1.5; +} + +.text-reading-prompt { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 16px; + font-size: 22px; + line-height: 1.45; + background: rgba(255, 250, 231, 0.66); + border-left: 5px solid #8e3027; +} + +.text-reading-label { + color: #8e3027; + font-size: 17px; + font-weight: 900; +} + +.text-reading-return { + min-height: 54px; + color: #fff5d9; + background: #8e3027; + border: 0; + border-radius: 8px; + font-size: 24px; + font-weight: 900; +} + +.text-reading-return::after { + border: 0; +} + +.compact-height .text-reading-dialog { + width: min(720px, calc(100vw - var(--safe-left) - var(--safe-right) - 20px)); + height: calc(100vh - var(--modal-top) - var(--safe-bottom) - 14px); + gap: 8px; + padding: 14px 20px 12px; +} + +.compact-height .text-reading-kicker { + font-size: 15px; +} + +.compact-height .text-reading-sheet { + gap: 10px; + padding: 10px 3px 14px; +} + +.compact-height .text-reading-title { + font-size: 24px; +} + +.compact-height .text-reading-body { + font-size: 23px; + line-height: 1.42; +} + +.compact-height.font-xlarge .text-reading-title { + font-size: 27px; +} + +.compact-height.font-xlarge .text-reading-body { + font-size: 25px; +} + +.compact-height.font-xlarge .text-reading-secondary, +.compact-height.font-xlarge .text-reading-prompt { + font-size: 20px; +} + +.compact-height .text-reading-secondary, +.compact-height .text-reading-prompt { + font-size: 18px; +} + +.compact-height .text-reading-return { + min-height: 48px; + font-size: 21px; +} + +.comic-page-audio-slot.is-playing { + color: #fff0c8; + background: #7f2a24; + border-color: #c26e5e; +} + +.comic-page-audio-slot.is-replay { + color: #f1e7ca; + background: #355344; + border-color: #76927e; +} + +.comic-page-audio-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 2px solid currentColor; + border-radius: 50%; + font-size: 15px; + font-weight: 900; + line-height: 1; +} + +.comic-page-audio-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.comic-page-audio-label, +.comic-page-audio-meta { + display: block; + overflow: hidden; + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comic-page-audio-label { + font-size: 16px; + font-weight: 850; +} + +.comic-page-audio-meta { + margin-top: 2px; + color: #c9b995; + font-size: 12px; +} + +.comic-bookmark { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 16px; + color: #dbc79f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + white-space: nowrap; +} + +.comic-bookmark-label, +.comic-page-count { + white-space: nowrap; +} + +.comic-page-count { + color: #f1cb73; +} + +.compact-height .comic-topbar { + gap: 7px; +} + +.compact-height .comic-catalog-button { + width: 78px; + min-width: 78px; + min-height: 48px; + padding: 0 9px; + font-size: 17px; +} + +.compact-height .comic-font-button { + width: 76px; + min-width: 76px; + min-height: 48px; + padding: 0 8px; + font-size: 17px; +} + +.compact-height .comic-top-previous-button { + width: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; +} + +.compact-height .c1-front-action-button { + width: 118px; + min-width: 118px; + min-height: 48px; + padding: 0 7px; + font-size: 16px; +} + +.compact-height .comic-page-audio-slot { + width: 128px; + min-width: 128px; + padding: 4px 6px; + grid-template-columns: 26px minmax(0, 1fr); +} + +.compact-height .comic-page-audio-icon { + width: 26px; + height: 26px; +} + +.compact-height .comic-page-audio-label { + font-size: 15px; +} + +.compact-height .comic-page-audio-meta { + font-size: 11px; +} + +.compact-height .comic-bookmark { + gap: 7px; + overflow: hidden; + font-size: 16px; +} + +.comic-reader { + position: relative; + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + padding-right: var(--safe-right); + padding-bottom: var(--safe-bottom); + padding-left: var(--safe-left); + background: #17100c; + flex-direction: column; +} + +/* 第一章试行母版:正面看画,点击后选择,解释放在纸页背面。 */ +.c1-book-topbar { + gap: 10px; + background: #2b1c15; + border-bottom: 3px solid #913027; +} + +.c1-book-topbar.is-front { + position: absolute; + z-index: 30; + inset: 0 0 auto 0; + background: rgba(33, 24, 19, 0.94); + border-bottom: 1px solid rgba(205, 171, 106, 0.34); + box-shadow: none; + transition: opacity 160ms ease, background-color 160ms ease; +} + +.c1-book-topbar.is-front.is-controls-hidden { + opacity: 0; + background: transparent; + border-bottom-color: transparent; + pointer-events: none; +} + +.c1-book-topbar.is-front.is-controls-revealed { + opacity: 1; + pointer-events: auto; +} + +.c1-book-topbar.is-front .comic-bookmark { + justify-content: center; +} + +.c1-book-topbar .comic-bookmark { + margin-right: auto; +} + +.c1-audio-slot { + width: 104px; + min-width: 104px; +} + +.c1-audio-slot .comic-page-audio-meta { + display: none; +} + +/* “直接看文字”与“试听本页”都保持整词显示。文字占位态不再挤进图标, + * 让四个字在最窄横屏上也完整显示,同时保留同一个固定槽位。 */ +.c1-audio-slot.is-text-mode { + grid-template-columns: minmax(0, 1fr); + text-align: center; +} + +.c1-audio-slot.is-text-mode .comic-page-audio-icon { + display: none; +} + +.c1-comic-page { + position: relative; + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #17100c; +} + +.c1-front-stage { + position: relative; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #1e1510; + border: 1px solid rgba(213, 178, 112, 0.42); + box-shadow: inset 0 0 36px rgba(0, 0, 0, 0.2); +} + +.c1-front-image, +.c1-front-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.c1-front-reveal-gate { + position: absolute; + z-index: 12; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-front-reveal-gate::after { + border: 0; +} + +.c1-front-first-hint { + position: absolute; + z-index: 13; + bottom: max(18px, calc(var(--safe-bottom) + 12px)); + left: 50%; + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 10px 18px; + color: #fff1c9; + background: rgba(31, 21, 15, 0.9); + border: 1px solid rgba(239, 205, 136, 0.76); + border-radius: 24px; + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.46); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.c1-front-image { + z-index: 1; + display: block; +} + +.c1-front-grain { + z-index: 3; + pointer-events: none; + background: + linear-gradient(0deg, rgba(30, 18, 11, 0.22), transparent 28%), + repeating-linear-gradient(0deg, rgba(61, 40, 24, 0.02) 0, rgba(61, 40, 24, 0.02) 1px, transparent 1px, transparent 5px); + box-shadow: inset 0 0 48px rgba(42, 25, 15, 0.16); +} + +.c1-front-fallback { + position: absolute; + z-index: 2; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 30px; + color: #4e3b2b; + background: #d8c49d; + flex-direction: column; + font-size: 20px; + line-height: 1.5; + text-align: center; +} + +.c1-full-page-tap { + position: absolute; + z-index: 5; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.c1-person-hotspot { + position: absolute; + z-index: 7; + min-width: 104px; + min-height: 104px; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.c1-person-hotspot::after, +.c1-full-page-tap::after, +.c1-previous-page::after, +.c1-back-button::after, +.c1-natural-choice::after, +.c1-retry-button::after, +.c1-tray-close::after { + border: 0; +} + +.c1-hotspot-ring { + position: absolute; + inset: -4px; + opacity: 0.3; + border: 0; + border-radius: 48% 52% 46% 54%; + background: radial-gradient(ellipse at center, rgba(244, 215, 151, 0.2) 0 45%, rgba(151, 69, 52, 0.16) 58%, transparent 72%); + box-shadow: none; + animation: c1-ink-breathe 2.8s ease-in-out infinite; +} + +@keyframes c1-ink-breathe { + 0%, 100% { opacity: 0.28; transform: scale(0.98); } + 50% { opacity: 0.62; transform: scale(1.025); } +} + +.c1-front-folio { + position: absolute; + z-index: 9; + right: 18px; + bottom: 16px; + left: auto; + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + padding: 0 12px; + color: #f3dfb6; + background: rgba(35, 24, 18, 0.5); + border: 0; + border-radius: 5px; + box-shadow: none; + pointer-events: none; +} + +.c1-front-folio-copy { + display: flex; + min-width: 0; + align-items: baseline; + gap: 12px; +} + +.c1-front-kicker { + flex: none; + color: #e8c675; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.c1-front-title { + overflow: hidden; + font-family: "Songti SC", "STSong", serif; + font-size: 24px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c1-front-hint { + color: #f0d797; + font-size: 15px; + font-weight: 750; + white-space: nowrap; +} + +.c1-previous-page { + position: absolute; + z-index: 11; + bottom: 16px; + left: 15px; + display: flex; + width: 50px; + height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #ebd6aa; + background: rgba(43, 29, 20, 0.62); + border: 1px solid rgba(181, 142, 86, 0.72); + border-radius: 6px; + font-size: 35px; + font-weight: 700; + line-height: 1; +} + +.c1-back-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.c1-back-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1 / span 3; +} + +.c1-back-image { + width: 100%; + height: 100%; +} + +.c1-back-image-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + padding: 22px; + color: #5b4835; + background: #d2bd93; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.c1-back-stamp { + position: absolute; + bottom: 14px; + left: 14px; + padding: 3px 0 3px 9px; + color: #e9c879; + background: transparent; + border: 0; + border-left: 3px solid #a64236; + border-radius: 0; + font-size: 14px; + font-weight: 850; + letter-spacing: 1px; + pointer-events: none; +} + +.c1-scroll-hint { + z-index: 4; + display: block; + width: 100%; + margin: 0; + padding: 3px 12px; + color: #6f4b35; + background: rgba(233, 214, 170, 0.72); + border-top: 1px solid rgba(119, 82, 52, 0.22); + border-right: 3px solid rgba(145, 49, 40, 0.72); + box-shadow: 0 -10px 14px rgba(241, 226, 189, 0.86); + font-size: 16px; + font-weight: 750; + grid-column: 2; + grid-row: 2; + pointer-events: none; + text-align: right; +} + +.c1-back-copy { + min-width: 0; + min-height: 0; + height: 100%; + grid-column: 2; + grid-row: 1; +} + +.c1-back-copy-inner { + display: flex; + min-height: 100%; + justify-content: center; + gap: 9px; + padding: 18px 24px 15px; + flex-direction: column; +} + +.c1-back-kicker { + display: block; + color: #8f3028; + font-size: 16px; + font-weight: 850; + letter-spacing: 1px; +} + +.c1-back-title { + display: block; + color: #2f231a; + font-family: "Songti SC", "STSong", serif; + font-size: 28px; + font-weight: 900; + line-height: 1.25; +} + +.font-xlarge .c1-back-title { + font-size: 31px; +} + +.c1-back-body, +.c1-back-secondary, +.c1-back-reason, +.c1-back-family, +.c1-back-callout, +.c1-back-action { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.5; +} + +.font-xlarge .c1-back-body, +.font-xlarge .c1-back-secondary, +.font-xlarge .c1-back-reason, +.font-xlarge .c1-back-family, +.font-xlarge .c1-back-callout, +.font-xlarge .c1-back-action { + font-size: 23px; +} + +.c1-back-secondary, +.c1-back-reason { + color: #654f39; +} + +.c1-back-callout, +.c1-back-action { + padding: 10px 13px; + background: rgba(255, 250, 232, 0.54); + border-left: 5px solid #a58954; +} + +.c1-back-action { + border-left-color: #8f3028; +} + +.c1-back-label { + display: block; + margin-bottom: 3px; + color: #8f3028; + font-size: 16px; + font-weight: 900; +} + +.c1-back-family { + color: #6c332c; + font-family: "Songti SC", "STSong", serif; +} + +.c1-memory-secondary-actions { + display: grid; + gap: 8px; + margin-top: 2px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.c1-memory-secondary-action { + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 6px 10px; + color: #604632; + background: transparent; + border: 1px solid rgba(118, 87, 58, 0.46); + border-radius: 5px; + font-size: 16px; + font-weight: 760; +} + +.c1-memory-secondary-action::after { + border: 0; +} + +.c1-back-footer { + display: flex; + min-width: 0; + overflow: hidden; + gap: 10px; + padding: 9px 12px; + background: rgba(106, 74, 44, 0.09); + border-top: 1px solid rgba(107, 77, 46, 0.28); + grid-column: 2; + grid-row: 3; + justify-self: stretch; +} + +.c1-back-button { + display: flex; + width: 0; + min-width: 0; + min-height: 54px; + flex: 1 1 0; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + color: #f6e8c6; + background: #4b3427; + border: 2px solid #806247; + border-radius: 8px; + font-size: 19px; + font-weight: 850; +} + +.c1-back-button.primary { + background: #913128; + border-color: #ad5a4e; +} + +.c1-memory-footer { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.c1-memory-footer .c1-back-button { + width: 100%; + min-height: 54px; + padding: 7px 10px; + font-size: 18px; +} + +.c1-back-button.report, +.c1-back-button.share { + color: #513a28; + background: #ead9ad; + border-color: #9d8059; +} + +.c1-decision-layer { + position: fixed; + z-index: 80; + top: var(--topbar-height); + right: var(--safe-right); + bottom: var(--safe-bottom); + left: var(--safe-left); + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + background: #17100c; +} + +.c1-interaction-paper { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(83, 55, 32, 0.028) 0, rgba(83, 55, 32, 0.028) 1px, transparent 1px, transparent 5px), + #f1e2bd; + grid-template-columns: minmax(210px, 2fr) minmax(0, 3fr); + grid-template-rows: minmax(0, 1fr); +} + +.c1-emotion-interaction-paper { + position: absolute; + z-index: 10; + top: var(--topbar-height); + right: 0; + bottom: 0; + left: 0; + width: auto; +} + +.c1-interaction-evidence { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(232, 202, 143, 0.025) 0, rgba(232, 202, 143, 0.025) 1px, transparent 1px, transparent 5px), + #38271f; + border-right: 3px solid #883028; + grid-column: 1; + grid-row: 1; +} + +.c1-interaction-image { + display: block; + width: 100%; + height: 100%; +} + +.c1-interaction-panel { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + justify-content: center; + gap: 12px; + overflow: hidden; + padding: 16px 18px; + flex-direction: column; + grid-column: 2; + grid-row: 1; +} + +.c1-interaction-panel .c1-tray-heading { + padding-right: 94px; +} + +.c1-choice-stack { + display: grid; + min-width: 0; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(70px, auto)); +} + +.c1-retry-panel { + align-items: stretch; +} + +.c1-decision-tray, +.c1-emotion-tray { + position: relative; + z-index: 30; + display: grid; + width: 100%; + min-height: 118px; + align-items: stretch; + gap: 10px; + padding: 13px 104px 13px 15px; + color: #34271d; + background: rgba(244, 231, 196, 0.98); + border: 3px solid #9e845e; + border-radius: 11px; + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.34); + grid-template-columns: minmax(210px, 1.05fr) repeat(2, minmax(0, 1fr)); +} + +.c1-emotion-tray { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + width: auto; +} + +.c1-tray-heading { + display: flex; + min-width: 0; + justify-content: center; + gap: 4px; + flex-direction: column; +} + +.c1-tray-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 900; +} + +.c1-tray-question { + font-size: 20px; + font-weight: 850; + line-height: 1.36; +} + +.c1-natural-choice { + display: flex; + min-width: 0; + min-height: 70px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #3e2d21; + background: #f1dfb3; + border: 3px solid #8d7353; + border-radius: 9px; + font-size: 18px; + font-weight: 850; + line-height: 1.35; + text-align: left; +} + +.c1-natural-choice:active { + background: #e6c98b; +} + +.c1-tray-close { + position: absolute; + top: 9px; + right: 9px; + display: flex; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + color: #4a3527; + background: #f4e2b7; + border: 2px solid #8d7353; + border-radius: 6px; + font-size: 16px; + font-weight: 800; +} + +.c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(210px, 0.7fr); + grid-template-rows: auto auto; +} + +.c1-retry-title { + align-self: end; + color: #713329; + font-size: 22px; + font-weight: 900; +} + +.c1-retry-copy { + align-self: start; + color: #5f4a36; + font-size: 18px; + font-weight: 700; + line-height: 1.4; +} + +.c1-retry-button { + display: flex; + min-height: 58px; + align-items: center; + justify-content: center; + margin: 0; + padding: 8px 12px; + color: #f8e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-size: 19px; + font-weight: 850; + grid-column: 2; + grid-row: 1 / span 2; +} + +.c1-event-result-back { + height: 100%; +} + +.comic-normal-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + color: #34271d; + background: #17100c; +} + +.comic-art-column { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 6px; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; +} + +.comic-art-stage { + position: relative; + flex: none; + max-width: 100%; + max-height: 100%; + overflow: hidden; + background: #cbb78d; + border: 1px solid rgba(222, 194, 143, 0.46); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.comic-art-image, +.comic-paper-grain { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-art-image { + z-index: 0; + display: block; +} + +.comic-paper-grain { + z-index: 2; + pointer-events: none; + background: + linear-gradient(0deg, rgba(45, 28, 17, 0.12), transparent 38%), + repeating-linear-gradient(0deg, rgba(62, 43, 25, 0.025) 0, rgba(62, 43, 25, 0.025) 1px, transparent 1px, transparent 4px); + box-shadow: inset 0 0 42px rgba(56, 38, 24, 0.18); +} + +.comic-evidence-composite { + position: absolute; + z-index: 6; + right: 5%; + bottom: 7%; + display: flex; + box-sizing: border-box; + width: min(62%, 430px); + max-height: 78%; + overflow: hidden; + gap: 5px; + padding: 14px 16px 15px; + color: #f7e9c9; + background: rgba(43, 31, 23, 0.92); + border: 2px solid rgba(220, 184, 112, 0.88); + border-left: 7px solid #a63b2e; + border-radius: 7px; + box-shadow: 0 12px 28px rgba(20, 12, 8, 0.34); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + pointer-events: none; +} + +.comic-evidence-kicker { + color: #e9c576; + font-size: 15px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-evidence-label { + font-size: 24px; + font-weight: 900; + line-height: 1.22; +} + +.comic-evidence-detail { + display: -webkit-box; + overflow: hidden; + color: #eadcc1; + font-size: 17px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.comic-art-fallback { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #5c4935; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.14), transparent 32%), + #d4c39d; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.cover-copy { + position: absolute; + z-index: 5; + top: 12%; + left: 8%; + display: flex; + max-width: 58%; + padding: 18px 24px; + color: #f5e7c8; + background: rgba(41, 29, 21, 0.82); + border-left: 7px solid #9f3028; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.cover-kicker { + color: #e2bf75; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + letter-spacing: 3px; +} + +.cover-title { + margin-top: 7px; + font-size: 34px; + font-weight: 900; + letter-spacing: 3px; +} + +.comic-actor-hotspot { + position: absolute; + z-index: 10; + min-width: 96px; + min-height: 96px; + overflow: visible; + padding: 0; + color: transparent; + background: transparent; + border: 0; + box-shadow: none; +} + +.comic-actor-hotspot.is-quiet { + background: transparent; + box-shadow: none; +} + +.comic-fallback-actor-button { + position: absolute; + z-index: 10; + left: 18px; + right: 18px; + bottom: 16px; + display: flex; + box-sizing: border-box; + width: calc(100% - 36px); + min-width: 0; + min-height: 62px; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 9px 16px; + color: #f8e8c7; + background: rgba(51, 35, 24, 0.92); + border: 2px solid #d4ad62; + border-left: 7px solid #9b3028; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + text-align: left; +} + +.comic-fallback-actor-button.is-seen { + color: #e6f1e2; + background: rgba(40, 76, 58, 0.94); + border-color: #98bd9d; +} + +.fallback-proof-label { + flex: none; + padding: 4px 8px; + color: #6c251f; + background: #e6c87f; + border-radius: 999px; + font-size: 15px; + font-weight: 850; +} + +.fallback-actor-action { + min-width: 0; + flex: 1 1 auto; + font-size: 19px; + font-weight: 850; + line-height: 1.25; + white-space: nowrap; +} + +.hotspot-ink-ring { + position: absolute; + inset: -5px; + opacity: 0.44; + border: 3px solid rgba(210, 169, 92, 0.82); + border-radius: 48% 52% 45% 55%; + box-shadow: 0 0 0 6px rgba(159, 48, 40, 0.12); + animation: comic-ink-hint 2.8s ease-in-out 0.35s infinite; +} + +.hotspot-guide-label { + position: absolute; + right: 50%; + bottom: 4px; + min-width: 82px; + transform: translateX(50%); + padding: 5px 10px; + color: #fff0c8; + background: rgba(70, 42, 27, 0.9); + border: 2px solid rgba(224, 190, 119, 0.88); + border-radius: 999px; + box-shadow: 0 5px 12px rgba(31, 18, 10, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.comic-actor-hotspot.is-seen .hotspot-ink-ring { + display: none; +} + +@keyframes comic-ink-hint { + 0%, 100% { opacity: 0.32; transform: scale(0.97); } + 50% { opacity: 0.68; transform: scale(1.04); } +} + +.comic-caption-strip { + display: grid; + box-sizing: border-box; + width: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: stretch; + color: #34271d; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 5px), + #f0e1b9; + border-left: 4px solid #8f2c25; +} + +.comic-caption-copy { + min-width: 0; + min-height: 0; + height: 100%; + padding: 18px 22px 16px; +} + +.comic-cover-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 18px; + grid-template-columns: minmax(190px, 0.9fr) minmax(0, 1.1fr); +} + +.comic-cover-caption-heading { + min-width: 0; +} + +.comic-cover-caption-layout .comic-caption-heading, +.comic-cover-caption-layout .comic-caption-kicker { + margin-bottom: 0; +} + +.comic-cover-caption-text { + min-width: 0; + padding-left: 18px; + border-left: 2px solid rgba(143, 48, 40, 0.34); +} + +.comic-ensemble-caption-layout, +.comic-event-caption-layout { + display: grid; + min-width: 0; + min-height: 100%; + align-items: center; + gap: 16px; +} + +.comic-ensemble-caption-layout { + grid-template-columns: minmax(150px, 0.72fr) minmax(0, 1.28fr); +} + +.comic-ensemble-caption-layout > .comic-caption-heading { + margin-bottom: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-caption-copy, +.comic-event-cue { + min-width: 0; +} + +.comic-ensemble-caption-copy, +.comic-event-cue { + padding-left: 16px; + border-left: 2px solid rgba(143, 48, 40, 0.28); +} + +.comic-event-caption-layout { + grid-template-columns: minmax(0, 1.62fr) minmax(150px, 0.88fr); +} + +.comic-event-caption-layout .comic-caption { + font-size: 19px; + line-height: 1.42; +} + +.comic-event-caption-layout .comic-tap-prompt { + margin-top: 0; +} + +.comic-outside-actor-button { + display: flex; + box-sizing: border-box; + width: 160px; + max-width: 100%; + min-width: 0; + min-height: 52px; + align-items: center; + justify-content: center; + padding: 7px 9px; + color: #f7e8c5; + background: #8f3028; + border: 2px solid #ad5a4e; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + line-height: 1.3; + text-align: center; +} + +.comic-art-tap-note { display: block; color: #76533d; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; font-size: 14px; font-weight: 750; line-height: 1.25; } +.comic-event-cue .comic-art-tap-note { margin-bottom: 6px; } + +.comic-event-caption-layout .comic-seen-row { + align-items: flex-start; + margin-top: 0; + flex-direction: column; +} + +.comic-caption-heading { + display: block; + margin-bottom: 10px; + color: #8f3028; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.3; +} + +.comic-caption-kicker { + display: block; + margin-bottom: 5px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 2px; +} + +.comic-caption { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 750; + line-height: 1.52; +} + +.comic-secondary-caption { + display: block; + margin-top: 4px; + color: #735b42; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.48; +} + +.comic-tap-prompt { + display: block; + margin-top: 5px; + color: #8e2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.45; +} + +.comic-tap-prompt.is-seen { + color: #37614c; +} + +.comic-seen-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 8px; +} + +.seen-chip { + flex: none; + transform: rotate(-3deg); + padding: 3px 7px; + color: #8d2923; + border: 2px double #9f3028; + border-radius: 4px; + font-size: 17px; + font-weight: 900; +} + +.comic-seen-row .comic-tap-prompt { + margin-top: 0; +} + +.comic-page-nav { + display: grid; + box-sizing: border-box; + min-width: 0; + align-items: center; + gap: 5px; + padding: 9px 10px; + grid-template-columns: minmax(72px, 1fr) auto minmax(72px, 1fr); + background: rgba(105, 74, 45, 0.08); + border-left: 1px solid rgba(107, 77, 46, 0.26); +} + +.page-turn-button { + position: relative; + display: flex; + width: auto; + min-height: 52px; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 6px; + color: #f6e8c6; + background: #493326; + border: 2px solid #806247; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.25; +} + +.page-turn-button::after, +.special-page-button::after, +.memory-action-cell::after { + border: 0; +} + +.page-turn-button.next { + background: #8f3028; + border-color: #ad5a4e; +} + +.page-turn-button.is-locked { + color: #735f47; + background: #ddcba2; + border-color: #a9936e; +} + +.page-turn-button.is-disabled { + opacity: 0.58; +} + +.folio { + min-width: 48px; + color: #715b40; + font-family: Georgia, serif; + font-size: 17px; + text-align: center; +} + +.comic-special-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + color: #35281e; + background: #eedfb8; +} + +.special-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #2c1e16; + border-right: 5px solid #8e3027; +} + +.special-art-image, +.special-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.special-art-fallback { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px; + color: #eadbb9; + background: + radial-gradient(circle at 38% 42%, rgba(151, 86, 58, 0.2), transparent 32%), + #3a2a20; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.55; + text-align: center; +} + +.special-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.82), transparent 48%); +} + +.special-art-copy { + position: absolute; + z-index: 2; + right: 24px; + bottom: 24px; + left: 24px; + display: flex; + color: #f4e5c4; + flex-direction: column; +} + +.special-kicker { + color: #e8c877; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 800; + letter-spacing: 2px; +} + +.special-title { + margin-top: 5px; + font-size: 32px; + font-weight: 900; +} + +.special-caption { + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + line-height: 1.55; +} + +.emotion-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.memory-panel { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) auto; +} + +.emotion-paper { + min-width: 0; + min-height: 0; + height: 100%; + padding: 22px 26px 18px; +} + +.emotion-context { + display: flex; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 2px solid rgba(143, 48, 40, 0.28); + flex-direction: column; +} + +.emotion-context-kicker { + color: #8f3028; + font-size: 15px; + font-weight: 850; + letter-spacing: 1px; +} + +.emotion-context-title { + margin-top: 3px; + font-size: 25px; + font-weight: 900; + line-height: 1.25; +} + +.emotion-context-caption { + margin-top: 6px; + color: #5c4632; + font-size: 18px; + line-height: 1.4; +} + +.emotion-paper .emotion-prompt { + display: block; + margin-top: 0; + font-size: 26px; + line-height: 1.38; +} + +.emotion-choice-dock { + display: grid; + min-width: 0; + gap: 6px; + padding: 6px 10px; + grid-template-columns: 1fr; + background: rgba(111, 79, 45, 0.07); + border-top: 1px solid rgba(107, 77, 46, 0.22); +} + +.emotion-choice-dock .emotion-choice { + width: auto; + max-width: none; + min-height: 58px; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 8px; + border-width: 2px; + border-radius: 8px; + font-size: 16px; + justify-self: stretch; + line-height: 1.25; +} + +.emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; +} + +.emotion-page-footer.single-action { + grid-template-columns: minmax(0, 1fr); +} + +.emotion-page-footer .special-page-button { + width: 100%; +} + +.comic-emotion-result .feedback { + display: block; + margin-top: 14px; + font-size: 20px; + line-height: 1.48; +} + +.special-page-footer { + display: grid; + min-width: 0; + flex: none; + gap: 8px; + padding: 9px 12px max(9px, var(--safe-bottom)); + grid-template-columns: minmax(92px, 2fr) minmax(0, 3fr); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.special-page-button { + position: relative; + display: flex; + min-width: 0; + min-height: 54px; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 10px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 8px; + font-size: 18px; + font-weight: 850; + line-height: 1.25; +} + +.special-page-button.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.special-page-button.is-disabled { + opacity: 0.35; +} + +.comic-control-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 24px; + text-align: center; + white-space: nowrap; +} + +.season-close-page { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-rows: minmax(0, 1fr) minmax(62px, auto); + color: #35281e; + background: #ead9af; +} + +.season-close-body { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.season-close-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(204, 173, 117, 0.035) 0, rgba(204, 173, 117, 0.035) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid #8e3027; +} + +.season-close-main-image { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +.season-close-art-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #f0dfba; + flex-direction: column; + font-size: 18px; + line-height: 1.45; + text-align: center; +} + +.season-close-detail-inset { + position: absolute; + z-index: 3; + width: 112px; + height: 112px; + overflow: hidden; + background: #d3bd8e; + border: 3px solid #f1dfb7; + border-radius: 8px; + box-shadow: 0 7px 18px rgba(19, 12, 8, 0.42); +} + +.season-close-detail-inset.anchor-top-right { + top: 10px; + right: 10px; +} + +.season-close-detail-image { + position: absolute; + max-width: none; + max-height: none; +} + +.season-close-detail-label { + position: absolute; + z-index: 2; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #fff1cb; + background: rgba(52, 34, 23, 0.9); + border-radius: 4px; + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.season-close-copy { + box-sizing: border-box; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #f0e2bd; +} + +.season-close-copy-sheet { + box-sizing: border-box; + display: flex; + min-height: 100%; + padding: 12px 15px 16px; + flex-direction: column; +} + +.season-close-kicker { + color: #913128; + font-size: 16px; + font-weight: 900; + letter-spacing: 2px; +} + +.season-close-caption { + display: block; + margin-top: 5px; + font-size: 22px; + font-weight: 900; + line-height: 1.34; +} + +.season-close-memory-card { + display: flex; + margin-top: 9px; + padding: 9px 11px; + background: #f6ebce; + border: 2px solid #9d8058; + box-shadow: 0 6px 15px rgba(62, 42, 28, 0.12); + flex-direction: column; +} + +.season-close-card-label, +.season-close-line-label { + color: #913128; + font-size: 15px; + font-weight: 850; +} + +.season-close-era { + margin-top: 2px; + color: #6d533a; + font-size: 15px; +} + +.season-close-quote { + margin-top: 4px; + font-size: 20px; + font-weight: 850; + line-height: 1.32; +} + +.season-close-life-line, +.season-close-family-line { + display: flex; + margin-top: 6px; + padding-top: 5px; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-size: 16px; + line-height: 1.32; +} + +.season-close-tail { + display: block; + margin-top: 8px; + color: #604934; + font-size: 16px; + font-weight: 700; + line-height: 1.35; +} + +.season-close-actions { + position: relative; + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.season-close-actions .memory-action-cell { + min-height: 54px; +} + +.font-xlarge .season-close-caption { + font-size: 23px; +} + +.font-xlarge .season-close-quote { + font-size: 21px; +} + +.font-xlarge .season-close-life-line, +.font-xlarge .season-close-family-line { + font-size: 17px; +} + +.memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + background: + repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.028) 0, rgba(85, 54, 31, 0.028) 1px, transparent 1px, transparent 6px), + #e9d8ab; +} + +.memory-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #2c1e16; + border-right: 5px solid #8e3027; +} + +.memory-art-image, +.memory-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.memory-cliffhanger-copy { + display: flex; + margin-bottom: 9px; + padding: 9px 11px; + color: #4d3929; + background: #ead7aa; + border-left: 5px solid #8f3028; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; + line-height: 1.38; +} + +.memory-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(34, 22, 15, 0.86), transparent 44%); +} + +.memory-cliffhanger { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + display: flex; + color: #f2e3bd; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 19px; + font-weight: 700; + line-height: 1.5; +} + +.memory-paper { + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + padding: 14px 16px 12px; +} + +.memory-card-sheet { + position: relative; + display: flex; + box-sizing: border-box; + min-width: 0; + justify-content: center; + min-height: 100%; + padding: 18px 22px; + background: #f3e8c9; + border: 2px solid #9d8058; + box-shadow: 0 9px 22px rgba(62, 42, 28, 0.16); + flex-direction: column; +} + +.memory-stamp { + position: absolute; + top: 18px; + right: 20px; + transform: rotate(6deg); + padding: 8px; + color: #923026; + border: 3px double #9f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-era { + color: #6d533a; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; +} + +.memory-person { + margin-top: 3px; + color: #8f2f27; + font-size: 27px; + font-weight: 900; +} + +.memory-quote { + display: block; + max-width: 88%; + margin-top: 10px; + font-size: 24px; + font-weight: 850; + line-height: 1.48; +} + +.memory-action, +.memory-family { + display: flex; + margin-top: 9px; + padding-top: 8px; + color: #574431; + border-top: 1px solid rgba(113, 83, 50, 0.24); + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + line-height: 1.5; +} + +.memory-family-toggle { + display: flex; + min-height: 48px; + margin-top: 8px; + padding: 6px 14px; + align-items: center; + justify-content: center; + color: #6b3d2d; + background: rgba(255, 248, 226, 0.72); + border: 2rpx solid rgba(126, 77, 45, 0.45); + border-radius: 10px; + font-size: 18px; + font-weight: 750; + line-height: 1.25; +} + +.memory-label { + margin-bottom: 3px; + color: #8f3028; + font-size: 17px; + font-weight: 850; +} + +.memory-ending-label { + margin-bottom: 9px; + color: #8f3028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-actions { + display: grid; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + gap: 7px; + padding: 8px 10px max(8px, var(--safe-bottom)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: rgba(111, 79, 45, 0.1); + border-top: 1px solid rgba(107, 77, 46, 0.28); +} + +.memory-action-cell { + position: relative; + display: flex; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 50px; + align-items: center; + justify-content: center; + margin: 0; + padding: 5px 8px; + color: #f3e2bd; + background: #4b3628; + border: 2px solid #806247; + border-radius: 7px; + font-size: 17px; + font-weight: 850; + justify-self: stretch; + line-height: 22px; +} + +.memory-action-visible { + position: relative; + z-index: 1; + display: block; + width: 100%; + pointer-events: none; + line-height: 22px; + text-align: center; + white-space: nowrap; +} + +.memory-action-cell.report { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.memory-action-cell.share { + color: #5c432f; + background: #ead9b2; + border-color: #9a7e56; +} + +.memory-action-cell.primary { + background: #8f3028; + border-color: #ad5a4e; +} + +.c09-p08 .memory-paper-content { display: grid; height: 100%; min-height: 0; gap: 6px; grid-template-rows: auto minmax(0, 1fr); } +.c09-p08 .memory-card-sheet { display: flex; height: 100%; min-height: 0; align-items: stretch; justify-content: flex-start; flex-direction: column; } +.c09-p08 .memory-short-lines { display: flex; min-height: 0; justify-content: space-evenly; gap: 3px; margin-top: 2px; flex: 1; flex-direction: column; } +.c09-p08 .memory-short-line { display: block; padding: 2px 0; color: #574431; border-top: 1px solid rgba(113, 83, 50, 0.24); font-size: 17px; font-weight: 750; line-height: 1.2; } +.c09-p08 .memory-actions { display: flex; flex-wrap: wrap; } +.c09-p08 .memory-action-cell { flex: 1 1 0; } +.c09-p08 .memory-action-cell.primary { min-width: 100%; flex: 1 0 100%; } + +.memory-action-cell.is-disabled { + opacity: 0.42; +} + +.comic-focus-visual { + position: relative; + height: 210px; + overflow: hidden; + margin: -30rpx -34rpx 18rpx; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #201711; +} + +.comic-focus-scene, +.comic-focus-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.comic-focus-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(28, 20, 15, 0.86), transparent 55%); +} + +.comic-focus-name { + position: absolute; + z-index: 3; + bottom: 12px; + left: 16px; + display: flex; + color: #e5c98c; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; +} + +.focus-name { + margin-top: 1px; + color: #fff0ca; + font-family: "Songti SC", "STSong", serif; + font-size: 31px; + font-weight: 900; +} + +.font-xlarge .comic-caption { + font-size: 24px; +} + +.font-xlarge .comic-secondary-caption, +.font-xlarge .comic-tap-prompt { + font-size: 19px; +} + +.font-xlarge .memory-action, +.font-xlarge .memory-family { + font-size: 21px; +} + +.font-xlarge .emotion-context-title { + font-size: 28px; +} + +.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; +} + +.font-xlarge .emotion-context-caption, +.font-xlarge .memory-cliffhanger-copy { + font-size: 20px; +} + +@media (max-height: 620px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-art-column { + padding: 6px; + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-special-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .comic-caption-copy { + padding: 8px 12px; + } + + .comic-cover-caption-layout { + align-content: start; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-cover-caption-layout .comic-caption-kicker { + font-size: 14px; + line-height: 1.15; + letter-spacing: 0; + } + + .comic-cover-caption-layout .comic-caption-heading { + margin-top: 2px; + font-size: 20px; + line-height: 1.18; + } + + .comic-cover-caption-layout .comic-cover-caption-text { + padding-top: 9px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.28); + border-left: 0; + font-size: 18px; + line-height: 1.35; + } + + .comic-ensemble-caption-layout, + .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-ensemble-caption-layout > .comic-caption-heading { + font-size: 20px; + line-height: 1.2; + } + + .comic-ensemble-caption-copy, + .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.24); + border-left: 0; + } + + .comic-ensemble-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; + } + + .comic-event-caption-layout { + grid-template-columns: minmax(0, 1fr); + } + + .comic-event-caption-layout .comic-caption, + .font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 20px; + line-height: 1.38; + } + + .comic-event-caption-layout .comic-tap-prompt, + .font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.34; + } + + .comic-caption-heading { + margin-bottom: 7px; + font-size: 23px; + } + + .comic-caption { + font-size: 20px; + line-height: 1.45; + } + + .font-xlarge .comic-caption { + font-size: 23px; + } + + .comic-secondary-caption, + .comic-tap-prompt { + margin-top: 5px; + font-size: 18px; + line-height: 1.42; + } + + .font-xlarge .comic-secondary-caption, + .font-xlarge .comic-tap-prompt { + font-size: 20px; + } + + .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } + + .page-turn-button { + min-width: 0; + min-height: 52px; + font-size: 17px; + } + + .comic-page-nav .folio { + display: none; + } + + .cover-copy { + top: 8%; + padding: 13px 18px; + } + + .cover-title { + font-size: 29px; + } + + .special-art-copy { + right: 18px; + bottom: 18px; + left: 18px; + } + + .special-title { + font-size: 27px; + } + + .special-caption { + font-size: 18px; + line-height: 1.42; + } + + .emotion-paper { + padding: 10px 12px 8px; + } + + .emotion-context { + margin-bottom: 8px; + padding-bottom: 7px; + } + + .emotion-context-title { + font-size: 21px; + } + + .emotion-context-caption { + margin-top: 3px; + font-size: 16px; + line-height: 1.3; + } + + .emotion-paper .emotion-prompt { + font-size: 21px; + line-height: 1.28; + } + + .emotion-choice-dock { + gap: 5px; + padding: 5px 8px; + } + + .emotion-choice-dock .emotion-choice { + min-height: 52px; + gap: 6px; + padding: 5px 7px; + grid-template-columns: 30px minmax(0, 1fr); + font-size: 16px; + line-height: 1.22; + } + + .emotion-choice-dock .choice-number { + width: 30px; + height: 30px; + font-size: 16px; + } + + .emotion-page-footer { + gap: 6px; + padding: 6px 8px max(6px, var(--safe-bottom)); + } + + .emotion-page-footer .special-page-button { + min-height: 52px; + } + + .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + padding: 0; + gap: 0; + } + + .memory-cliffhanger-copy { + margin-bottom: 5px; + padding: 6px 8px; + font-size: 18px; + line-height: 1.3; + } + + .memory-paper { + padding: 8px; + } + + .memory-card-sheet { + display: grid; + min-height: 100%; + align-content: start; + column-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 8px 10px; + } + + .memory-stamp { + top: 8px; + right: 8px; + padding: 4px; + border-width: 2px; + font-size: 14px; + } + + .memory-era { + grid-column: 1 / -1; + padding-right: 72px; + font-size: 15px; + } + + .memory-person { + grid-column: 1 / -1; + margin-top: 1px; + font-size: 22px; + } + + .memory-quote { + grid-column: 1 / -1; + max-width: 100%; + margin-top: 4px; + font-size: 19px; + line-height: 1.3; + } + + .memory-action, + .memory-family { + min-width: 0; + margin-top: 4px; + padding-top: 3px; + font-size: 16px; + line-height: 1.2; + } + + .memory-label { + margin-bottom: 1px; + font-size: 14px; + } + + .memory-actions { + gap: 4px; + padding: 6px max(6px, var(--safe-right)) max(6px, var(--safe-bottom)) 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .memory-action-cell { + min-height: 54px; + padding: 4px 3px; + font-size: 17px; + line-height: 22px; + } + + .comic-focus-visual { + height: 128px; + margin: -12px -16px 10px; + } + + .comic-focus-name { + right: 12px; + bottom: 9px; + left: 12px; + } + + .comic-fallback-actor-button { + left: 10px; + right: 10px; + bottom: 9px; + width: calc(100% - 20px); + min-width: 0; + min-height: 56px; + gap: 7px; + padding: 7px 11px; + } + + .fallback-proof-label { + font-size: 14px; + } + + .fallback-actor-action { + font-size: 17px; + } +} + +@media (max-width: 700px) and (max-height: 400px) { + .compact-height.font-xlarge .emotion-paper .emotion-prompt { + font-size: 23px; + } + + .compact-height.font-xlarge .emotion-choice-dock .emotion-choice { + font-size: 17px; + } + + .compact-height.font-xlarge .emotion-choice-dock { + gap: 5px; + } + + .compact-height .memory-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .memory-paper { + padding: 5px; + } + + .compact-height .memory-card-sheet { + column-gap: 6px; + grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); + padding: 5px 8px; + } + + .compact-height .memory-stamp { + top: 5px; + right: 5px; + padding: 3px; + font-size: 13px; + } + + .compact-height .memory-era { + padding-right: 62px; + font-size: 15px; + line-height: 1.15; + } + + .compact-height .memory-person { + font-size: 21px; + line-height: 1.12; + } + + .compact-height .memory-quote { + margin-top: 2px; + font-size: 18px; + line-height: 1.18; + } + + .compact-height .memory-action, + .compact-height .memory-family { + margin-top: 2px; + padding-top: 2px; + font-size: 17px; + line-height: 1.16; + } + + .compact-height.font-xlarge .memory-action, + .compact-height.font-xlarge .memory-family { + font-size: 19px; + } + + .compact-height.font-xlarge .memory-era { + font-size: 17px; + } + + .compact-height.font-xlarge .memory-person { + font-size: 24px; + } + + .compact-height.font-xlarge .memory-quote { + font-size: 21px; + } + + .compact-height.font-xlarge .memory-label { + font-size: 16px; + } + + .compact-height .memory-label { + margin-bottom: 0; + font-size: 14px; + line-height: 1.1; + } + + .compact-height .memory-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-height .memory-action-cell { + min-height: 50px; + padding: 4px 8px; + } + + .compact-height .c09-p08 .memory-paper { padding: 4px 5px; } + .compact-height .c09-p08 .memory-paper-content { gap: 2px; } + .compact-height .c09-p08 .memory-cliffhanger-copy { display: block; margin-bottom: 0; padding: 2px 5px; font-size: 14px; line-height: 1.12; } + .compact-height .c09-p08 .memory-ending-label { margin-right: 5px; color: #8f3028; font-size: 12px; line-height: 1.12; } + .compact-height .c09-p08 .memory-card-sheet { display: flex; padding: 4px 6px; border-width: 1px; flex-direction: column; } + .compact-height .c09-p08 .memory-stamp { top: 4px; right: 4px; padding: 2px; border-width: 1px; font-size: 11px; line-height: 1.05; } + .compact-height .c09-p08 .memory-era { padding-right: 63px; font-size: 13px; line-height: 1.08; } + .compact-height .c09-p08 .memory-person { margin-top: 0; font-size: 17px; line-height: 1.06; } + .compact-height .c09-p08 .memory-short-lines { gap: 2px; margin-top: 1px; } + .compact-height .c09-p08 .memory-short-line { padding: 1px 0; font-size: 17px; line-height: 1.16; } + .compact-height .c09-p08 .memory-actions { gap: 4px; padding: 3px 6px max(3px, var(--safe-bottom)); } + .compact-height .c09-p08 .memory-action-cell { min-height: 48px; padding: 2px 3px; font-size: 14px; line-height: 18px; } + + .compact-height .season-close-actions { + min-height: 62px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .compact-height .season-close-actions .memory-action-cell { + min-height: 54px; + padding: 4px 6px; + } + + .compact-height .season-close-copy-sheet { + padding: 9px 11px 13px; + } + + .compact-height .decision-dialog { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .compact-height .character-story-inner { + padding: 10px 14px 28px; + } + + .compact-height .comic-focus-visual { + height: 108px; + margin: -10px -14px 8px; + } + + .compact-height .judgement-scroll-inner { + padding: 8px 9px; + } + + .compact-height .judgement-question { + font-size: 20px; + line-height: 1.16; + } + + .compact-height.font-xlarge .judgement-question { + font-size: 21px; + } + + .compact-height .judgement-options { + gap: 5px; + margin-top: 6px; + } + + .compact-height .judgement-button { + min-height: 56px; + } +} + +@media (min-height: 621px) { + .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); + } + + .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; + } + + .comic-page-nav { + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; + } +} + +/* + * WeChat DevTools can report the portrait media-query height after the + * simulator has rotated to landscape. The page logic already derives the + * real short-landscape state, so keep the lianhuanhua spread tied to that + * state as the authoritative fallback. + */ +.compact-height .comic-normal-page { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + grid-template-rows: minmax(0, 1fr); +} + +.compact-height .comic-art-column { + padding: 6px; +} + +.compact-height .comic-caption-strip { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + border-top: 0; + border-left: 4px solid #8f2c25; +} + +.compact-height .comic-caption-strip.has-outside-actor-guide { + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.compact-height .comic-caption-copy { + padding: 8px 12px; +} + +.compact-height .emotion-context { + margin-bottom: 4px; + padding-bottom: 4px; +} + +.compact-height .emotion-context-kicker, +.compact-height .emotion-context-title { + display: none; +} + +.compact-height .emotion-context-caption { + margin-top: 0; + font-size: 17px; + line-height: 1.28; +} + +.compact-height.font-xlarge .emotion-context-caption { + font-size: 18px; +} + +.compact-height .comic-outside-action-dock { display: grid; min-width: 0; align-items: center; gap: 6px; padding: 0 12px 6px; grid-template-columns: minmax(0, 1fr); } +.compact-height .comic-outside-action-dock.has-art-tap-note { grid-template-columns: auto minmax(0, 1fr); } +.compact-height .comic-outside-action-dock .comic-art-tap-note { max-width: 82px; font-size: 13px; line-height: 1.2; text-align: center; } +.compact-height .comic-outside-action-dock .comic-outside-actor-dock { width: 100%; min-height: 52px; margin: 0; padding: 6px; font-size: 16px; line-height: 1.25; } + +.compact-height .comic-cover-caption-layout, +.compact-height .comic-ensemble-caption-layout, +.compact-height .comic-event-caption-layout { + align-content: start; + gap: 9px; + grid-template-columns: minmax(0, 1fr); +} + +.compact-height .comic-cover-caption-text, +.compact-height .comic-ensemble-caption-copy, +.compact-height .comic-event-cue { + padding-top: 8px; + padding-left: 0; + border-top: 2px solid rgba(143, 48, 40, 0.26); + border-left: 0; +} + +.compact-height .comic-event-caption-layout .comic-caption { + font-size: 18px; + line-height: 1.35; +} + +.compact-height .comic-event-caption-layout .comic-tap-prompt { + font-size: 17px; + line-height: 1.3; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-caption { + font-size: 21px; + line-height: 1.4; +} + +.compact-height.font-xlarge .comic-event-caption-layout .comic-tap-prompt { + font-size: 19px; + line-height: 1.35; +} + +.compact-height .comic-page-nav { + display: grid; + gap: 4px; + padding: 7px 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(107, 77, 46, 0.26); + border-left: 0; +} + +.compact-height .page-turn-button { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 52px; + font-size: 17px; +} + +.compact-height .comic-page-nav .folio { + display: none; +} + +.compact-height .c1-book-topbar { + gap: 7px; +} + +.compact-height .c1-audio-slot { + width: 92px; + min-width: 92px; +} + +.compact-height .c1-front-folio { + right: 10px; + bottom: 9px; + left: auto; + min-height: 40px; + padding: 0 13px; +} + +.compact-height .c1-front-title { + font-size: 21px; +} + +.compact-height .c1-front-hint { + font-size: 15px; +} + +.compact-height .c1-previous-page { + bottom: 9px; + left: 9px; + width: 48px; + height: 52px; +} + +.compact-height .c1-back-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-back-copy-inner { + gap: 5px; + padding: 9px 14px 8px; +} + +.compact-height .c1-back-kicker, +.compact-height .c1-back-label { + font-size: 14px; +} + +.compact-height .c1-back-title { + font-size: 24px; + line-height: 1.16; +} + +.compact-height.font-xlarge .c1-back-title { + font-size: 27px; +} + +.compact-height .c1-back-body, +.compact-height .c1-back-secondary, +.compact-height .c1-back-reason, +.compact-height .c1-back-family, +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + font-size: 18px; + line-height: 1.35; +} + +.compact-height.font-xlarge .c1-back-body, +.compact-height.font-xlarge .c1-back-secondary, +.compact-height.font-xlarge .c1-back-reason, +.compact-height.font-xlarge .c1-back-family, +.compact-height.font-xlarge .c1-back-callout, +.compact-height.font-xlarge .c1-back-action { + font-size: 20px; +} + +.compact-height .c1-back-callout, +.compact-height .c1-back-action { + padding: 6px 9px; +} + +.compact-height .c1-back-footer { + gap: 7px; + padding: 6px 8px; +} + +.compact-height .c1-memory-footer { + gap: 5px; +} + +.compact-height .c1-memory-footer .c1-back-button { + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-back-button { + min-height: 50px; + font-size: 17px; +} + +.compact-height .c1-decision-layer { + padding: 0; +} + +.compact-height .c1-interaction-paper { + grid-template-columns: minmax(190px, 2fr) minmax(0, 3fr); +} + +.compact-height .c1-interaction-panel { + gap: 7px; + padding: 10px 12px; +} + +.compact-height .c1-interaction-panel .c1-tray-heading { + padding-right: 88px; +} + +.compact-height .c1-choice-stack { + gap: 7px; + grid-template-rows: repeat(2, minmax(62px, auto)); +} + +.compact-height .c1-decision-tray, +.compact-height .c1-emotion-tray { + min-height: 105px; + gap: 7px; + padding: 9px 49px 9px 10px; + grid-template-columns: minmax(185px, 1fr) repeat(2, minmax(0, 1fr)); +} + +.compact-height .c1-emotion-tray { + right: 8px; + bottom: 8px; + left: 8px; +} + +.compact-height .c1-tray-question { + font-size: 18px; + line-height: 1.28; +} + +.compact-height .c1-natural-choice { + min-height: 62px; + padding: 6px 9px; + font-size: 17px; + line-height: 1.28; +} + +.compact-height .c1-tray-close { + top: 7px; + right: 7px; + width: auto; + min-width: 82px; + height: 48px; + min-height: 48px; + font-size: 16px; +} + +.compact-height .c1-retry-tray { + grid-template-columns: minmax(0, 1.3fr) minmax(190px, 0.7fr); +} + +.compact-height .c1-retry-title { + font-size: 20px; +} + +.compact-height .c1-retry-copy { + font-size: 17px; + line-height: 1.3; +} + +.compact-height .c1-retry-button { + min-height: 52px; + font-size: 17px; +} diff --git a/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapterLayout.js b/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapterLayout.js new file mode 100644 index 0000000..8a8e1f0 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapterLayout.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapterPages.js b/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapterPages.js new file mode 100644 index 0000000..20ec268 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-game/pages/chapter/chapterPages.js @@ -0,0 +1,1249 @@ +const COMIC_CHAPTER_ID = 'S01-C04' +const CHAPTER_ONE_ID = 'S01-C01' +const CHAPTER_TWO_ID = 'S01-C02' +const CHAPTER_THREE_ID = 'S01-C03' +const CHAPTER_FIVE_ID = 'S01-C05' +const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ + 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), + 'S01-C04-P02': Object.freeze([ + 'S01-C04-MS001', + 'S01-C04-MS002-ATTR', + 'S01-C04-MS002', + ]), + 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), + 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), + 'S01-C04-P05': Object.freeze([ + 'S01-C04-MS006', + 'S01-C04-MS007', + 'S01-C04-MS008', + 'S01-C04-MS009', + 'S01-C04-MS010', + 'S01-C04-MS011', + ]), + 'S01-C04-P06': Object.freeze([ + 'S01-C04-MS012-ATTR', + 'S01-C04-MS012', + 'S01-C04-MS013', + 'S01-C04-MS014', + ]), + 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), + 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), +}) +// This generated data is copied into every chapter subpackage. Keeping the +// dependency package-local is important: a file referenced only from a +// subpackage can otherwise be omitted from the main package by WeChat's +// unused-file optimisation, leaving the chapter page blank at runtime. +const productionComicChapters = require('../../data/productionComicPages') +const memoryCards = require('../../../data/memoryCards') +const { + attachPlayableVisuals, +} = require('../../data/playableVisualPolicy') +const { + allowsFixedDetailInset, + getComicPresentationMode, + getComicPageLayoutException, + resolveComicPageLayout, +} = require('./chapterLayout') + +function chapterPackageRoot(value) { + const chapter = Math.min( + 15, + Math.max(1, Math.floor(Number(value) || 1)), + ) + return chapter === 1 + ? 'package-game' + : `package-chapter-${String(chapter).padStart(2, '0')}` +} + +function pageId(chapterId, pageNumber) { + return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` +} + +function findPerson(chapter, instanceId) { + return (chapter.people || []).find( + (person) => person.instanceId === instanceId, + ) || null +} + +function actorHotspot(personOrPosition) { + const position = personOrPosition && personOrPosition.position + ? personOrPosition.position + : (personOrPosition || {}) + const x = Math.max(0, Number(position.xPercent) || 0) + const y = Math.max(0, Number(position.yPercent) || 0) + const width = Math.max(12, Number(position.widthPercent) || 18) + const height = Math.max(22, Number(position.heightPercent) || 48) + const expandedX = Math.max(0, x - 2.5) + const expandedY = Math.max(0, y - 3) + const expandedWidth = Math.min(100 - expandedX, width + 5) + const expandedHeight = Math.min(100 - expandedY, height + 6) + return { + xPercent: expandedX, + yPercent: expandedY, + widthPercent: expandedWidth, + heightPercent: expandedHeight, + style: [ + `left:${expandedX}%`, + `top:${expandedY}%`, + `width:${expandedWidth}%`, + `height:${expandedHeight}%`, + ].join(';'), + } +} + +function fixedHotspot(position) { + const x = Math.max(0, Number(position && position.x) || 0) + const y = Math.max(0, Number(position && position.y) || 0) + const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) + const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) + return { + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + style: [ + `left:${x}%`, + `top:${y}%`, + `width:${width}%`, + `height:${height}%`, + ].join(';'), + } +} + +function fixedDetailInset(position) { + if (!position || typeof position !== 'object') return null + const x = Math.max(0, Math.min(99, Number(position.x) || 0)) + const y = Math.max(0, Math.min(99, Number(position.y) || 0)) + const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) + const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) + const requestedAnchor = String(position.anchor || '') + const anchor = requestedAnchor === 'top-right' + || requestedAnchor === 'top-center' + ? requestedAnchor + : 'top-left' + const labelAnchorClass = position.labelAnchor === 'bottom-left' + ? 'label-bottom-left' + : '' + const imageWidth = 10000 / width + const imageHeight = 10000 / height + const imageLeft = -(x / width * 100) + const imageTop = -(y / height * 100) + return { + label: String(position.label || ''), + xPercent: x, + yPercent: y, + widthPercent: width, + heightPercent: height, + anchor, + anchorClass: `anchor-${anchor}`, + ...(labelAnchorClass ? { labelAnchorClass } : {}), + imageMode: 'scaleToFill', + imageStyle: [ + `left:${imageLeft.toFixed(3)}%`, + `top:${imageTop.toFixed(3)}%`, + `width:${imageWidth.toFixed(3)}%`, + `height:${imageHeight.toFixed(3)}%`, + ].join(';'), + } +} + +function productionPageFor(stablePageId) { + const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) + const productionChapter = match + ? productionComicChapters[match[1]] + : null + const pages = productionChapter && productionChapter.pages + return Array.isArray(pages) + ? pages.find((page) => page.pageId === stablePageId) || null + : null +} + +function attachFixedDetailInset(page) { + if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { + return page + } + const productionPage = productionPageFor(page.pageId) + const detailInset = fixedDetailInset( + productionPage && productionPage.programDetailInset, + ) + return detailInset ? { ...page, detailInset } : page +} + +function fixedProgramEvidenceInset(source) { + if (!source || typeof source !== 'object') return null + const countValue = Number(source.countValue) + if ( + source.kind !== 'order-count' + || !Number.isInteger(countValue) + || countValue < 0 + ) { + return null + } + const anchor = source.anchor === 'top-right' + ? 'top-right' + : 'top-left' + return { + kind: 'order-count', + kicker: String(source.kicker || '画中近景'), + screenLabel: String(source.screenLabel || '手机点单'), + countLabel: String(source.countLabel || '已点'), + countValue, + countUnit: String(source.countUnit || '道'), + plusGlyph: String(source.plusGlyph || '+'), + note: String(source.note || ''), + ariaLabel: String(source.ariaLabel || ''), + anchor, + anchorClass: `anchor-${anchor}`, + } +} + +// Keep internal provenance / implementation language out of the reader-facing story. +function playerFacingCopy(value) { + return String(value || '') + .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') + .replace(/AI/g, '手机') + .replace(/证据链/g, '前后几样线索') + .replace(/证据来源/g, '物件上的记号') + .replace(/记录来源/g, '记下它从哪里来') + .replace(/来源标签/g, '物件上的记号') + .replace(/来源/g, '来处') +} + +function basePage(chapter, number, type, extra = {}) { + const packageRoot = chapterPackageRoot(chapter.chapterNumber) + const stablePageId = pageId(chapter.chapterId, number) + return { + pageId: stablePageId, + pageNumber: number, + type, + ...resolveComicPageLayout(stablePageId, type), + hotspotGuideOutside: type === 'event', + year: chapter.year, + location: chapter.location, + sceneAlt: chapter.sceneAlt, + // The art-specific path is intentionally only a declaration for the next + // illustration pass. Until that file exists, the reviewed era scene keeps + // the text game usable and the page engine testable. + illustrationAsset: + `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, + fallbackAsset: chapter.sceneAsset, + ...extra, + } +} + +function pageAudioDeclaration(source = {}) { + const declaration = { + audioCueIds: Array.isArray(source.audioCueIds) + ? [...source.audioCueIds] + : [], + } + // Cue IDs are production provenance, not complete page narration. C01 uses + // its two dedicated full-page player packages; C02-C15 may expose audio only + // through the approved remote full-page manifest and shared player. + for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { + if (Object.prototype.hasOwnProperty.call(source, field)) { + declaration[field] = String(source[field] || '') + } + } + if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { + const durationSeconds = Number(source.audioDurationSeconds) + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + throw new Error('page audio duration must be a non-negative number') + } + declaration.audioDurationSeconds = durationSeconds + } + return declaration +} + +function chapterFourAudioDeclaration(pageNumber) { + return pageAudioDeclaration({ + audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ + pageId(COMIC_CHAPTER_ID, pageNumber) + ], + }) +} + +function buildChapterFourPages(chapter) { + const eventCaptions = [ + { + caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', + tapPrompt: '点画中的赵建国,听听这一桌的起哄。', + question: '拿饭量证明能干,这样妥当吗?', + position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', + }, + { + caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', + tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', + question: '已经吃撑还不说、马上抬重物,妥当吗?', + position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, + assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', + }, + { + caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', + tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', + question: '提醒“别又急又撑”,等于主食一口不能吃吗?', + position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, + assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', + }, + { + caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', + tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', + question: '先拉凳、递水,再问清不适,这样更稳妥吗?', + position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, + assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第04回 · ${chapter.year}`, + headline: chapter.title, + caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, + ...chapterFourAudioDeclaration(1), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '食堂里又比起了饭量', + caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', + secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', + primaryAction: '看看第三碗饭', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, + ...chapterFourAudioDeclaration(2), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const copy = eventCaptions[index] || {} + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: copy.position || (person ? person.position : null), + actorHotspot: actorHotspot(copy.position || person), + question: copy.question || event.question, + headline: event.label, + caption: copy.caption || event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, + shortDialogue: event.speech, + illustrationAsset: copy.assetName + ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` + : '', + ...chapterFourAudioDeclaration(index + 3), + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', + prompt: chapter.emotionMoment.prompt, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, + ...chapterFourAudioDeclaration(7), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C04-MC01', + characterName: chapter.emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: chapter.emotionMoment.tableEcho, + lifeAction: chapter.events[1].actionAdvice, + familyLine: '下回我嘴硬时,先给我拉把凳子。', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, + ...chapterFourAudioDeclaration(8), + }), + ) + + return pages +} + +function buildChapterOnePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H01', + caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', + question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', + shortDialogue: '不扫,就没有我的座了?', + tapPrompt: '点点小满,看看赵伯为什么停住了。', + hotspot: { x: 46, y: 6, w: 36, h: 88 }, + assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + 'S01-C01-MS005', + 'S01-C01-MS006', + ], + }, + { + eventId: 'S01-H02', + caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', + question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', + shortDialogue: '桌上可不能显得空。', + tapPrompt: '点点赵伯,听听他为什么还想加菜。', + hotspot: { x: 16, y: 6, w: 40, h: 90 }, + assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', + audioCueIds: [ + 'S01-C01-MS007', + 'S01-C01-MS008', + 'S01-C01-MS009-ATTR', + 'S01-C01-MS009', + ], + }, + { + eventId: 'S01-H03', + caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', + question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', + shortDialogue: '它不是没来过,是刚走。', + tapPrompt: '点点乐乐,看他在地上发现了什么。', + hotspot: { x: 31, y: 14, w: 49, h: 80 }, + assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', + audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], + }, + { + eventId: 'S01-H04', + caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', + question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', + shortDialogue: '十五桌的人……齐了吗?', + tapPrompt: '点点秦师傅,看看他为什么不肯出门。', + hotspot: { x: 51, y: 2, w: 42, h: 96 }, + assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', + audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], + }, + ] + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第01回 · ${chapter.year}`, + headline: chapter.title, + caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', + tapPrompt: '点一下,翻过来听个开头。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, + audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '十五桌,到底去哪儿了?', + caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', + secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', + tapPrompt: '先看看,谁在等,谁又躲开了。', + primaryAction: '先看看,大家都在看哪里', + illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', + audioCueIds: [ + 'S01-C01-MS002-ATTR', + 'S01-C01-MS002', + 'S01-C01-MS003', + 'S01-C01-MS004', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', + prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, + actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), + audioCueIds: ['S01-C01-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C01-MC01', + characterName: '赵建国', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', + lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', + familyLine: '下回别急着替我安排,先叫我一起商量。', + eraObject: '手写请柬与十五号旧铜牌', + }, + caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', + tapPrompt: '锅盖这一响,接着往下看。', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, + audioCueIds: ['S01-C01-MS013'], + }), + ) + return pages +} + +function buildChapterTwoPages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H05', + caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', + question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', + shortDialogue: '名字差一个,来路就差远了。', + tapPrompt: '点画中的林秀兰,看她把票名说清。', + hotspot: { x: 33, y: 1, w: 52, h: 96 }, + assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', + audioCueIds: ['S01-C02-MS007'], + }, + { + eventId: 'S01-H06', + caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', + question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', + shortDialogue: '我这不是先把自己认回来嘛。', + tapPrompt: '点画中的赵伯,陪他再认一遍。', + hotspot: { x: 6, y: 1, w: 51, h: 97 }, + assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', + audioCueIds: [ + 'S01-C02-MS002-ATTR', + 'S01-C02-MS002', + 'S01-C02-MS003', + 'S01-C02-MS004', + 'S01-C02-MS005', + 'S01-C02-MS006', + ], + }, + { + eventId: 'S01-H07', + caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', + question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', + shortDialogue: '我先记“待核对”,不写“就是”。', + tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', + hotspot: { x: 25, y: 8, w: 55, h: 89 }, + assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', + audioCueIds: [ + 'S01-C02-MS008', + 'S01-C02-MS009', + 'S01-C02-MS010', + ], + }, + { + eventId: 'S01-H08', + caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', + question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', + shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', + tapPrompt: '点画中的林秀兰,看她怎样留证。', + hotspot: { x: 31, y: 0, w: 58, h: 98 }, + assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', + audioCueIds: ['S01-C02-MS011'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第02回 · ${chapter.year}`, + headline: chapter.title, + caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, + audioCueIds: ['S01-C02-MT000'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '三个人,三种线索', + caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', + secondaryCaption: '', + primaryAction: '先看看,大家各自在看什么', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, + audioCueIds: ['S01-C02-MS001'], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', + prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, + actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), + audioCueIds: ['S01-C02-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C02-MC01', + characterName: '林秀兰', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', + lifeAction: '保留近照和尺寸,等待旧桌板出现。', + familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', + eraObject: '十五号铜牌与两枚破损饭菜票', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, + audioCueIds: ['S01-C02-MS012'], + }), + ) + return pages +} + +function buildChapterThreePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H09', + caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', + question: '没有清洁双手就直接抓食物,这样妥当吗?', + shortDialogue: '差点把车间也吃进去了,我先去洗。', + tapPrompt: '点画中的老吕,看看他从车间带回来的手。', + hotspot: { x: 18, y: 1, w: 56, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', + audioCueIds: [ + 'S01-C03-MS006', + 'S01-C03-MS007-ATTR', + 'S01-C03-MS007', + 'S01-C03-MS008', + ], + }, + { + eventId: 'S01-H10', + caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', + question: '一个人不提醒同伴就突然起身,这样妥当吗?', + shortDialogue: '这声早半拍,我的汤就保住了。', + tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', + hotspot: { x: 14, y: 1, w: 78, h: 97 }, + artAspectRatio: 16 / 9, + assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', + audioCueIds: ['S01-C03-MS010'], + }, + { + eventId: 'S01-H11', + caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', + question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', + shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', + tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', + hotspot: { x: 29, y: 1, w: 49, h: 97 }, + assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', + audioCueIds: [ + 'S01-C03-MS003', + 'S01-C03-MS004', + 'S01-C03-MS011', + ], + }, + { + eventId: 'S01-H12', + caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', + question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', + shortDialogue: '账能重算,回来吃饭的人不能漏。', + tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', + hotspot: { x: 29, y: 0, w: 56, h: 98 }, + artAspectRatio: 1280 / 540, + assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', + audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第03回 · ${chapter.year}`, + headline: chapter.title, + caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, + audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '一顿午饭,同时忙着六件事', + caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', + secondaryCaption: '', + primaryAction: '先看看,谁在忙什么', + artAspectRatio: 16 / 9, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS002-ATTR', + 'S01-C03-MS002', + 'S01-C03-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: overlay.question, + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + ...(overlay.artAspectRatio + ? { artAspectRatio: overlay.artAspectRatio } + : {}), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', + prompt: '你想怎样给这个不起眼的小唐留一点位置?', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, + actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), + audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C03-MC01', + characterName: '唐守安', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', + lifeAction: '进食前按条件把手清洁并擦干', + familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', + eraObject: '饭票与搪瓷饭盒', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, + audioCueIds: [ + 'S01-C03-MS013', + 'S01-C03-MS014', + 'S01-C03-MS015', + ], + }), + ) + return pages +} + +function buildChapterFivePages(chapter) { + const eventOverlays = [ + { + eventId: 'S01-H17', + caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', + shortDialogue: '算了,空一顿也能顶。', + tapPrompt: '点画中的老吕,看看他为什么又想转身。', + hotspot: { x: 20, y: 1, w: 53, h: 97 }, + assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }, + { + eventId: 'S01-H18', + caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', + shortDialogue: '先数人,别数功劳。', + tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', + hotspot: { x: 21, y: 1, w: 56, h: 97 }, + assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', + audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], + }, + { + eventId: 'S01-H19', + caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', + shortDialogue: '旧菜不赌,重新做。', + tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', + hotspot: { x: 29, y: 0, w: 55, h: 98 }, + assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', + audioCueIds: [ + 'S01-C05-MS008', + 'S01-C05-MS009', + 'S01-C05-MS010', + 'S01-C05-MS012', + ], + }, + { + eventId: 'S01-H20', + caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', + shortDialogue: '来晚的人,也得有地方坐。', + tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', + hotspot: { x: 47, y: 1, w: 43, h: 97 }, + assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', + audioCueIds: [ + 'S01-C05-MS011', + 'S01-C05-MS013', + 'S01-C05-MS014', + 'S01-C05-MS015', + ], + }, + ] + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第05回 · ${chapter.year}`, + headline: chapter.title, + caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', + primaryAction: '翻开这一回', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, + audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: '人报过了,饭却没有留下', + caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', + secondaryCaption: '', + primaryAction: '先看看,谁正准备走,谁正把人留下', + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, + audioCueIds: [ + 'S01-C05-MS002-ATTR', + 'S01-C05-MS002', + 'S01-C05-MS003-ATTR', + 'S01-C05-MS003', + ], + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + const overlay = eventOverlays[index] + if (!overlay || overlay.eventId !== event.hotspotId) { + throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: fixedHotspot(overlay.hotspot), + question: event.question, + evidence: playerFacingCopy(event.evidence), + headline: event.label, + caption: overlay.caption, + resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, + tapPrompt: overlay.tapPrompt, + shortDialogue: overlay.shortDialogue, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, + audioCueIds: overlay.audioCueIds, + })) + }) + + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', + prompt: chapter.emotionMoment.prompt, + actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, + audioCueIds: ['S01-C05-TE900'], + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: 'S01-C05-MC01', + characterName: '秦志成', + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: '有人为晚归的人留了一盏灯。', + lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', + familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', + eraObject: '第十五桌木牌与留饭灯', + }, + caption: chapter.cliffhanger, + illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, + audioCueIds: ['S01-C05-MS016'], + }), + ) + + return pages +} + +function reviewedProductionAsset(chapter, productionPage) { + return [ + '', + chapterPackageRoot(chapter.chapterNumber), + 'assets', + 'comic', + chapter.chapterId.toLowerCase(), + productionPage.assetName, + ].join('/') +} + +function buildReviewedProductionChapterPages(chapter) { + const productionChapter = productionComicChapters[chapter.chapterId] + const productionPages = productionChapter && productionChapter.pages + if (!Array.isArray(productionPages) || productionPages.length !== 8) { + throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) + } + + const [cover, ensemble, ...remainingPages] = productionPages + const eventPages = remainingPages.slice(0, 4) + const emotionPage = remainingPages[4] + const memoryPage = remainingPages[5] + const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) + const isSeasonClose = Boolean( + memoryLayoutException + && memoryLayoutException.behavior === 'season-close-detail-inset' + ) + const reviewedMemoryCard = memoryCards.find( + (card) => card.chapterId === chapter.chapterId, + ) + if (!reviewedMemoryCard) { + throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) + } + + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: cover.caption, + primaryAction: cover.interactionPrompt || '翻开这一回', + illustrationAsset: reviewedProductionAsset(chapter, cover), + ...pageAudioDeclaration(cover), + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: ensemble.caption, + secondaryCaption: '', + primaryAction: ensemble.interactionPrompt || '先看看画中人物', + illustrationAsset: reviewedProductionAsset(chapter, ensemble), + ...pageAudioDeclaration(ensemble), + }), + ] + + ;(chapter.events || []).forEach((event, index) => { + const productionPage = eventPages[index] + const person = findPerson(chapter, event.actorInstanceId) + const layoutException = productionPage + ? getComicPageLayoutException(productionPage.pageId) + : null + if ( + !productionPage + || productionPage.pageId !== pageId(chapter.chapterId, index + 3) + || productionPage.eventId !== event.hotspotId + || !productionPage.hotspot + ) { + throw new Error( + `${chapter.chapterId} production page does not match ${event.hotspotId}`, + ) + } + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + // Page-specific hit-area changes come only from the explicit exception + // registry. Visible guidance for every event is part of the shared + // actor-event template and always stays outside the artwork. + actorHotspot: fixedHotspot( + layoutException && layoutException.actorHotspot + ? layoutException.actorHotspot + : productionPage.hotspot, + ), + question: event.question, + headline: event.label, + caption: productionPage.caption, + secondaryCaption: productionPage.secondaryCaption || '', + outsideActionLabel: productionPage.outsideActionLabel || '', + artTapHint: productionPage.artTapHint || '', + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看这一刻。`, + shortDialogue: productionPage.shortDialogue || event.speech, + illustrationAsset: reviewedProductionAsset(chapter, productionPage), + ...(productionPage.programEvidenceInset + ? { + programEvidenceInset: fixedProgramEvidenceInset( + productionPage.programEvidenceInset, + ), + } + : {}), + ...pageAudioDeclaration(productionPage), + })) + }) + + if ( + emotionPage.emotionMomentId + !== chapter.emotionMoment.emotionMomentId + ) { + throw new Error(`${chapter.chapterId} emotion page is not locked to source`) + } + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: chapter.emotionMoment.emotionMomentId, + headline: chapter.emotionMoment.title, + caption: emotionPage.caption, + prompt: chapter.emotionMoment.prompt, + actorHotspot: emotionPage.hotspot + ? fixedHotspot(emotionPage.hotspot) + : null, + illustrationAsset: reviewedProductionAsset(chapter, emotionPage), + ...pageAudioDeclaration(emotionPage), + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: reviewedMemoryCard.cardId, + characterName: reviewedMemoryCard.characterName, + eraLine: reviewedMemoryCard.eraLine, + tableEcho: reviewedMemoryCard.tableEcho, + lifeAction: reviewedMemoryCard.lifeAction, + familyLine: reviewedMemoryCard.familyLine, + eraObject: reviewedMemoryCard.eraObject, + ...(Array.isArray(memoryPage.memoryDisplayLines) + && memoryPage.memoryDisplayLines.length + ? { + displayLines: memoryPage.memoryDisplayLines.map( + (line) => String(line), + ), + } + : {}), + }, + caption: memoryPage.cliffhanger || chapter.cliffhanger, + illustrationAsset: reviewedProductionAsset(chapter, memoryPage), + ...pageAudioDeclaration(memoryPage), + ...(isSeasonClose + ? { + isSeasonClose: true, + seasonCloseCaption: memoryPage.caption, + seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, + detailInset: fixedDetailInset(memoryPage.programDetailInset), + } + : {}), + }), + ) + return pages +} + +function chapterPeopleLine(chapter) { + const names = (chapter.people || []) + .map((person) => person.name) + .filter(Boolean) + .filter((name, index, allNames) => allNames.indexOf(name) === index) + .slice(0, 5) + if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' + return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` +} + +function appendGenericInteractivePages(pages, chapter) { + ;(chapter.events || []).forEach((event, index) => { + const person = findPerson(chapter, event.actorInstanceId) + pages.push(basePage(chapter, index + 3, 'event', { + eventId: event.hotspotId, + actorInstanceId: event.actorInstanceId, + actorName: event.actorName, + actorPortrait: event.actorPortrait || (person ? person.portrait : ''), + actorPosition: person ? person.position : null, + actorHotspot: actorHotspot(person), + question: event.question, + headline: event.label, + caption: event.actionDescription, + resolvedCaption: `这一页记下:${event.actionAdvice}`, + tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, + shortDialogue: event.speech, + })) + }) + + const emotionMoment = chapter.emotionMoment + pages.push( + basePage(chapter, 7, 'emotion', { + emotionMomentId: emotionMoment.emotionMomentId, + headline: emotionMoment.title, + caption: emotionMoment.sceneText, + prompt: emotionMoment.prompt, + }), + basePage(chapter, 8, 'memory', { + headline: '这一页,收进桂香岁月', + memoryCard: { + cardId: `${chapter.chapterId}-MC01`, + characterName: emotionMoment.character.name, + eraLine: `${chapter.year} · ${chapter.location}`, + tableEcho: emotionMoment.tableEcho, + lifeAction: chapter.events[0].actionAdvice, + familyLine: `回家聊一聊:${emotionMoment.prompt}`, + }, + caption: chapter.cliffhanger, + }), + ) +} + +function buildGenericChapterPages(chapter) { + const pages = [ + basePage(chapter, 1, 'cover', { + eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, + headline: chapter.title, + caption: chapter.narration, + primaryAction: '翻开这一回', + }), + basePage(chapter, 2, 'ensemble', { + eyebrow: `${chapter.year} · ${chapter.location}`, + headline: chapter.title, + caption: chapter.narration, + secondaryCaption: chapterPeopleLine(chapter), + primaryAction: '看看画中人物', + }), + ] + + appendGenericInteractivePages(pages, chapter) + return pages +} + +function buildComicPageModel(chapter, chapterNumber) { + if (!chapter || !chapter.chapterId) { + return { + mode: 'legacy', + chapterId: '', + chapterNumber: Number(chapterNumber) || 1, + pageSequence: [], + firstPageId: '', + lastPageId: '', + } + } + + let pageSequence = [] + let prototypeScope = '' + if (chapter.chapterId === COMIC_CHAPTER_ID) { + pageSequence = buildChapterFourPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_ONE_ID) { + pageSequence = buildChapterOnePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_TWO_ID) { + pageSequence = buildChapterTwoPages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_THREE_ID) { + pageSequence = buildChapterThreePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (chapter.chapterId === CHAPTER_FIVE_ID) { + pageSequence = buildChapterFivePages(chapter) + prototypeScope = 'full-eight-pages' + } else if (productionComicChapters[chapter.chapterId]) { + pageSequence = buildReviewedProductionChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } else { + pageSequence = buildGenericChapterPages(chapter) + prototypeScope = 'full-eight-pages' + } + + pageSequence = pageSequence.map(attachFixedDetailInset) + pageSequence = attachPlayableVisuals(pageSequence, chapter) + + return { + mode: 'comic', + chapterId: chapter.chapterId, + chapterNumber: Number(chapterNumber) || chapter.chapterNumber, + presentationMode: getComicPresentationMode(chapter.chapterId), + pageSequence, + firstPageId: pageSequence[0].pageId, + lastPageId: pageSequence[pageSequence.length - 1].pageId, + prototypeScope, + } +} + +function findPageIndex(model, requestedPageId) { + if (!model || !Array.isArray(model.pageSequence)) return -1 + return model.pageSequence.findIndex( + (page) => page.pageId === requestedPageId, + ) +} + +function getResumePageId( + model, + savedPageId, + completedIds = [], + chapterFinished = false, +) { + if (!model || model.mode !== 'comic' || !model.pageSequence.length) { + return '' + } + if (chapterFinished) return model.lastPageId + + const eventPages = model.pageSequence.filter((page) => page.type === 'event') + const savedPageIndex = findPageIndex(model, savedPageId) + if (!eventPages.length) { + return savedPageIndex >= 0 ? savedPageId : model.firstPageId + } + const validEventIds = new Set(eventPages.map((page) => page.eventId)) + const completed = new Set( + (Array.isArray(completedIds) ? completedIds : []).filter( + (eventId) => validEventIds.has(eventId), + ), + ) + const firstIncomplete = eventPages.find( + (page) => !completed.has(page.eventId), + ) + if (firstIncomplete) { + const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { + return savedPageId + } + if (savedPageIndex > firstIncompleteIndex) { + return firstIncomplete.pageId + } + return completed.size ? firstIncomplete.pageId : model.firstPageId + } + const emotionPage = model.pageSequence.find( + (page) => page.type === 'emotion', + ) + if (!emotionPage) return model.lastPageId + const emotionPageIndex = findPageIndex(model, emotionPage.pageId) + if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { + return savedPageId + } + return emotionPage.pageId +} + +module.exports = { + buildComicPageModel, + findPageIndex, + getResumePageId, + pageAudioDeclaration, +} diff --git a/TongjiUniApp/native/tang-detective/package-game/utils/assetManager.js b/TongjiUniApp/native/tang-detective/package-game/utils/assetManager.js new file mode 100644 index 0000000..3f8adb7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-game/utils/assetManager.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-game/utils/comicPageModel.js b/TongjiUniApp/native/tang-detective/package-game/utils/comicPageModel.js new file mode 100644 index 0000000..f6be7a9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-game/utils/comicPageModel.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/package-game/utils/comicReaderState.js b/TongjiUniApp/native/tang-detective/package-game/utils/comicReaderState.js new file mode 100644 index 0000000..4f01456 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/package-game/utils/comicReaderState.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/pages/cast/cast.js b/TongjiUniApp/native/tang-detective/pages/cast/cast.js new file mode 100644 index 0000000..09241dc --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/cast/cast.js @@ -0,0 +1,119 @@ +const rawCast = require('../../data/cast') +const { + getSettings, + saveSettings, +} = require('../../utils/storage') + +const readerDetails = { + 'tang-shouan': { + relationship: '唐明远的父亲、乐乐的爷爷,也是桂香老邻居信得过的唐大夫。', + visual: '常穿素色深靛蓝外套,背着旧棕布卫生包。衣着朴素,像每天都会遇见的邻家大夫。', + poseCount: 5, + }, + 'qin-zhicheng': { + relationship: '秦小满的长辈,桂香食堂和饭店里掌勺多年的秦师傅。', + visual: '宽头、粗颈、宽肩,前臂厚实;穿中式粗棉厨工褂、白围裙和低矮布帽。', + poseCount: 5, + }, + 'lin-xiulan': { + relationship: '桂香厂老工友,也是饭票、账本和旧物的保管人。', + visual: '常穿枣红或深棕工作外套。她不急着训人,更愿意用行动把事情说明白。', + poseCount: 5, + }, + 'zhao-jianguo': { + relationship: '大家叫他赵伯,是桂香厂出了名的劳动骨干和老工友。', + visual: '青年时穿结实工装,老年戴深蓝便帽、捧盖碗茶;豪爽有劲,也保留普通人的体面。', + poseCount: 5, + }, + 'tang-mingyuan': { + relationship: '唐守安的儿子、乐乐的父亲,和许多中年人一样忙工作也顾家。', + visual: '衣着随年代变化,腰腹和颈围也慢慢有了变化。这是多年生活留下的痕迹,不拿他的身形开玩笑。', + poseCount: 4, + }, + 'qin-xiaoman': { + relationship: '秦师傅的晚辈,如今接手照看桂香饭店。', + visual: '穿简洁的现代中式工作装,常带着软尺、纸质座位单和调台记录。', + poseCount: 4, + }, + lele: { + relationship: '唐明远的儿子、唐守安的孙子,是家里最敢把问题问出来的人。', + visual: '圆润、活泼、讨人喜欢。大人从家庭习惯上找原因,不把责任推给孩子。', + poseCount: 4, + }, + xiaozhen: { + relationship: '在现代生活里陪大家整理健康信息的人,需要时提醒大家联系专业医护。', + visual: '灰绿色开衫、白衬衫、低马尾,手里拿着记录夹;总在需要时陪一程,不抢别人的故事。', + poseCount: 4, + }, +} + +const cast = rawCast.map((character) => ({ + ...character, + relationship: readerDetails[character.id].relationship, + readerRole: character.id === 'xiaozhen' + ? '在故事需要回顾生活习惯时陪大家理一理;遇到拿不准的健康问题,会提醒大家联系专业医护,不替医生作决定。' + : character.role, + readerVisual: readerDetails[character.id].visual, + poseCount: readerDetails[character.id].poseCount, +})) + +Page({ + data: { + cast, + selected: null, + fontScale: 'large', + castListEnded: false, + }, + + onLoad() { + const settings = getSettings() + this.setData({ + fontScale: settings.fontScale === 'xlarge' ? 'xlarge' : 'large', + castListEnded: false, + }) + }, + + selectCharacter(event) { + const id = event.currentTarget.dataset.id + const selected = cast.find((character) => character.id === id) + this.setData({ selected }) + }, + + closeCharacter() { + this.setData({ selected: null }) + }, + + markListEnd() { + if (!this.data.castListEnded) { + this.setData({ castListEnded: true }) + } + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ fontScale }) + }, + + noop() {}, + + goBack() { + wx.reLaunch({ url: '/pages/home/home' }) + }, + + onShareAppMessage() { + return { + title: '唐侦探人物画谱:认识桂香故事里的人', + path: '/pages/cast/cast', + } + }, + + onShareTimeline() { + return { + title: '唐侦探人物画谱:认识桂香故事里的人', + query: '', + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/pages/cast/cast.json b/TongjiUniApp/native/tang-detective/pages/cast/cast.json new file mode 100644 index 0000000..28d4548 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/cast/cast.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "人物画谱", + "pageOrientation": "landscape" +} diff --git a/TongjiUniApp/native/tang-detective/pages/cast/cast.wxml b/TongjiUniApp/native/tang-detective/pages/cast/cast.wxml new file mode 100644 index 0000000..0022a5b --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/cast/cast.wxml @@ -0,0 +1,119 @@ + + + + + 桂香里的熟面孔 + 人物画谱 + + + + + + + + + + 点开一位人物,先认清他和家里人的关系,再看看他在桂香里的故事。 + + + + + + + + + + + {{item.name}} + {{item.eras}} + + {{item.title}} + {{item.relationship}} + 点开看故事 + + + + + + + {{castListEnded ? '人物都看见了' : '向下翻,还有更多人物'}} + + + + + + + + + + + + + + + {{selected.eras}} · {{selected.title}} + {{selected.name}} + + + + + + {{selected.relationship}} + + {{selected.readerRole}} + + {{selected.readerVisual}} + + + + + + + + + diff --git a/TongjiUniApp/native/tang-detective/pages/cast/cast.wxss b/TongjiUniApp/native/tang-detective/pages/cast/cast.wxss new file mode 100644 index 0000000..3103818 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/cast/cast.wxss @@ -0,0 +1,660 @@ +.cast-shell { + display: flex; + height: 100vh; + gap: 14rpx; + flex-direction: column; + overflow: hidden; +} + +.cast-head { + display: grid; + min-height: 62px; + flex: 0 0 auto; + grid-template-columns: 110px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 6px 10px; +} + +.cast-back, +.font-button, +.modal-close, +.modal-done { + min-width: 48px; + min-height: 48px; +} + +.cast-back { + width: 100%; + min-width: 0; + padding-right: 8px; + padding-left: 8px; +} + +.cast-heading-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.cast-title { + overflow: hidden; + font-size: 27px; + font-weight: 850; + letter-spacing: 2px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cast-heading-copy .eyebrow { + overflow: hidden; + font-size: 15px; + letter-spacing: 1px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cast-head-actions { + display: flex; + align-items: center; + gap: 10rpx; + justify-self: end; +} + +.font-button { + white-space: nowrap; +} + +/* The WeChat capsule occupies this part of a custom landscape title bar. */ +.capsule-safe-space { + width: 102px; + height: 40px; + flex: 0 0 102px; +} + +.cast-scroll { + min-height: 0; + flex: 1; +} + +.cast-intro { + padding: 2px 6px 10px; + color: #f4e5bd; + font-size: 17px; + line-height: 1.5; +} + +.cast-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 0 2px 20px; +} + +.cast-card { + display: grid; + width: 100%; + min-width: 0; + min-height: 144px; + overflow: hidden; + grid-template-columns: 132px minmax(0, 1fr); + text-align: left; + border-radius: 8px; +} + +.cast-card-hover { + transform: translateY(2rpx); + opacity: 0.88; +} + +.cast-image-wrap { + display: flex; + min-height: 144px; + align-items: center; + justify-content: center; + overflow: hidden; + background: #d4c29a; + border-right: 5px solid var(--cinnabar); +} + +.cast-portrait-window, +.modal-portrait-window { + position: relative; + overflow: hidden; + background: #dfcfaa; + border: 1px solid rgba(98, 65, 40, 0.35); +} + +.cast-portrait-window { + height: 126px; +} + +.cast-portrait-window.poses-5 { + width: 45px; +} + +.cast-portrait-window.poses-4 { + width: 56px; +} + +.cast-image { + position: absolute; + top: 0; + left: 0; + width: 224px; + height: 126px; +} + +.cast-copy { + display: flex; + justify-content: center; + padding: 10px 13px; + flex-direction: column; +} + +.name-row { + display: flex; + align-items: baseline; + gap: 8rpx; + flex-wrap: wrap; +} + +.cast-name { + font-size: 23px; + font-weight: 850; + line-height: 1.2; + white-space: nowrap; +} + +.eras { + color: var(--muted); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 14px; + white-space: nowrap; +} + +.cast-role-title { + margin-top: 4px; + color: #8d3028; + font-size: 17px; + font-weight: 750; + line-height: 1.3; +} + +.cast-relationship { + display: -webkit-box; + margin-top: 5px; + overflow: hidden; + color: #554333; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 15px; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.cast-hint { + margin-top: 7px; + color: var(--muted); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 15px; + font-weight: 700; + line-height: 1.35; +} + +.list-scroll-guide { + display: flex; + min-height: 40px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + gap: 10px; + color: #f4e5bd; + background: rgba(58, 40, 28, 0.94); + border: 1px solid rgba(222, 194, 139, 0.55); + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 750; +} + +.scroll-guide-mark { + color: #f0c86b; + font-size: 24px; + line-height: 1; +} + +.modal-backdrop { + position: fixed; + z-index: 100; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: + calc(10px + constant(safe-area-inset-top)) + calc(112px + constant(safe-area-inset-right)) + calc(10px + constant(safe-area-inset-bottom)) + calc(10px + constant(safe-area-inset-left)); + padding: + calc(10px + env(safe-area-inset-top)) + calc(112px + env(safe-area-inset-right)) + calc(10px + env(safe-area-inset-bottom)) + calc(10px + env(safe-area-inset-left)); + background: rgba(19, 13, 9, 0.82); +} + +.character-modal { + position: relative; + display: grid; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(240px, 42%) minmax(0, 58%); + grid-template-rows: minmax(0, 1fr); + border-radius: 10rpx; +} + +.modal-image-wrap { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + overflow: hidden; + background: #d1bf96; + border-right: 6rpx solid #7d2a23; +} + +.modal-portrait-window { + height: 560px; +} + +.modal-portrait-window.poses-5 { + width: 200px; +} + +.modal-portrait-window.poses-4 { + width: 249px; +} + +.modal-image { + position: absolute; + top: 0; + left: 0; + width: 996px; + height: 560px; +} + +.modal-copy { + position: relative; + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(76px, auto) minmax(0, 1fr) minmax(68px, auto); +} + +.modal-head { + display: flex; + min-height: 76px; + align-items: center; + padding: 12rpx 88px 8rpx 22rpx; + border-bottom: 2rpx solid rgba(125, 42, 35, 0.22); +} + +.modal-heading { + display: flex; + min-width: 0; + overflow: hidden; + flex: 1; + flex-direction: column; +} + +.modal-heading .eyebrow { + overflow: hidden; + font-size: 18rpx; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modal-name { + margin-top: 2rpx; + font-size: 42rpx; + font-weight: 900; + line-height: 1.12; +} + +.modal-close { + position: absolute; + z-index: 3; + top: 12px; + right: 12px; + display: flex; + width: 68px; + height: 48px; + min-width: 68px; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 0 8px; + color: #6f201b; + background: #fff8e5; + border: 2rpx solid #9f6d54; + border-radius: 8px; + box-shadow: 0 4px 10px rgba(48, 37, 28, 0.18); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 800; +} + +.modal-story-scroll { + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.modal-story { + display: flex; + padding: 12rpx 24rpx 28rpx; + flex-direction: column; +} + +.section-label { + margin-top: 10rpx; + color: #8d3028; + font-size: 22rpx; + font-weight: 850; +} + +.modal-text { + margin-top: 6rpx; + color: #4f3e30; + font-size: 23rpx; + line-height: 1.55; +} + +.modal-done { + width: 100%; + min-width: 48px; + min-height: 48px; + height: 48px; + padding: 0 14px; + font-size: 18px; +} + +.modal-footer { + display: flex; + min-height: 68px; + align-items: center; + padding: 8px 14px 12px; + background: rgba(243, 229, 189, 0.96); + border-top: 2rpx solid rgba(125, 42, 35, 0.22); +} + +.font-xlarge .cast-title { + font-size: 35rpx; +} + +.font-xlarge .cast-intro, +.font-xlarge .cast-role-title, +.font-xlarge .section-label { + font-size: 20px; +} + +.font-xlarge .cast-name { + font-size: 26px; +} + +.font-xlarge .eras, +.font-xlarge .cast-hint, +.font-xlarge .modal-heading .eyebrow { + font-size: 17px; +} + +.font-xlarge .cast-relationship, +.font-xlarge .list-scroll-guide { + font-size: 18px; +} + +.font-xlarge .modal-name { + font-size: 46rpx; +} + +.font-xlarge .modal-text { + font-size: 26rpx; +} + +@media (max-height: 620px) { + .cast-shell { + gap: 8px; + } + + .cast-scroll { + padding-left: 18px; + } + + .cast-head { + min-height: 62px; + grid-template-columns: 96px minmax(0, 1fr) auto; + gap: 8px; + padding: 6px 8px; + } + + .cast-back, + .font-button { + min-height: 48px; + font-size: 17px; + } + + .font-button { + padding: 0 12px; + } + + .cast-title { + font-size: 25px; + } + + .cast-heading-copy .eyebrow { + font-size: 15px; + letter-spacing: 2px; + } + + .cast-intro { + padding: 0 4px 8px; + font-size: 17px; + } + + .cast-grid { + gap: 10px; + padding: 0 2px 16px; + } + + .cast-card { + min-height: 144px; + grid-template-columns: 132px minmax(0, 1fr); + } + + .cast-image-wrap { + min-height: 144px; + } + + .cast-copy { + padding: 8px 10px; + } + + .cast-name { + font-size: 22px; + } + + .eras { + font-size: 14px; + } + + .cast-role-title { + font-size: 17px; + } + + .cast-relationship { + font-size: 15px; + } + + .cast-hint { + margin-top: 6px; + font-size: 15px; + } + + .modal-head { + min-height: 68px; + padding: 8px 82px 6px 14px; + } + + .modal-copy { + grid-template-rows: minmax(68px, auto) minmax(0, 1fr) minmax(64px, auto); + } + + .modal-heading .eyebrow { + font-size: 14px; + letter-spacing: 1px; + } + + .modal-name { + font-size: 30px; + } + + .modal-story { + padding: 7px 16px 16px; + } + + .modal-close { + top: 10px; + right: 10px; + width: 64px; + min-width: 64px; + height: 48px; + min-height: 48px; + font-size: 17px; + } + + .modal-footer { + min-height: 64px; + padding: 8px 12px; + } + + .modal-done { + height: 48px; + min-height: 48px; + } + + .section-label { + margin-top: 7px; + font-size: 18px; + } + + .modal-text { + margin-top: 4px; + font-size: 18px; + line-height: 1.5; + } + + .modal-backdrop { + padding-right: calc(98px + constant(safe-area-inset-right)); + padding-left: calc(32px + constant(safe-area-inset-left)); + padding-right: calc(98px + env(safe-area-inset-right)); + padding-left: calc(32px + env(safe-area-inset-left)); + } + + .modal-portrait-window { + height: 280px; + } + + .modal-portrait-window.poses-5 { + width: 100px; + } + + .modal-portrait-window.poses-4 { + width: 125px; + } + + .modal-image { + width: 498px; + height: 280px; + } + + .font-xlarge .cast-title { + font-size: 28px; + } + + .font-xlarge .cast-intro, + .font-xlarge .cast-role-title, + .font-xlarge .section-label { + font-size: 20px; + } + + .font-xlarge .cast-name { + font-size: 25px; + } + + .font-xlarge .eras, + .font-xlarge .cast-hint, + .font-xlarge .modal-heading .eyebrow { + font-size: 16px; + } + + .font-xlarge .cast-relationship, + .font-xlarge .list-scroll-guide { + font-size: 17px; + } + + .font-xlarge .modal-name { + font-size: 33px; + } + + .font-xlarge .modal-text { + font-size: 21px; + } +} + +@media (max-width: 720px) and (orientation: landscape) { + .cast-head { + grid-template-columns: 96px minmax(0, 1fr) auto; + gap: 8px; + } + + .capsule-safe-space { + width: 88px; + flex-basis: 88px; + } + + .font-button { + min-width: 84px; + padding-right: 9px; + padding-left: 9px; + } + + .cast-heading-copy .eyebrow { + display: none; + } + + .cast-grid { + grid-template-columns: minmax(0, 1fr); + } + + .cast-card { + grid-template-columns: 150px minmax(0, 1fr); + } + + .modal-backdrop { + padding-right: calc(98px + env(safe-area-inset-right)); + } + + .character-modal { + grid-template-columns: minmax(210px, 40%) minmax(0, 60%); + } +} diff --git a/TongjiUniApp/native/tang-detective/pages/catalog/catalog.js b/TongjiUniApp/native/tang-detective/pages/catalog/catalog.js new file mode 100644 index 0000000..a0aef33 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/catalog/catalog.js @@ -0,0 +1,102 @@ +const chapters = require('../../data/chapters') +const { + getProgress, + saveProgress, + resetStoryProgress, + getSettings, +} = require('../../utils/storage') +const { chapterRoute } = require('../../utils/chapterRoute') + +Page({ + data: { + chapters: [], + fontScale: 'large', + catalogListEnded: false, + }, + + onShow() { + const progress = getProgress() + const settings = getSettings() + const completed = new Set(progress.completedChapters) + const lastChapter = Number(progress.lastChapter) || 1 + const completedHotspots = progress.completedHotspots || {} + this.setData({ + chapters: chapters.map((chapter) => ({ + ...chapter, + completed: completed.has(chapter.chapterId), + current: chapter.number === lastChapter, + readingLabel: completed.has(chapter.chapterId) + ? '重新翻看这一回' + : chapter.number === lastChapter + ? '上次读到这里' + : '翻开这一回', + eventCount: Array.isArray(completedHotspots[chapter.chapterId]) + ? completedHotspots[chapter.chapterId].length + : 0, + })), + lastChapter, + fontScale: settings.fontScale === 'xlarge' ? 'xlarge' : 'large', + catalogListEnded: false, + }) + }, + + markListEnd() { + if (!this.data.catalogListEnded) { + this.setData({ catalogListEnded: true }) + } + }, + + openChapter(event) { + const chapter = Number(event.currentTarget.dataset.chapter) + const progress = getProgress() + const chapterMeta = chapters.find((item) => item.number === chapter) + const replay = Boolean( + chapterMeta + && progress.completedChapters.includes(chapterMeta.chapterId), + ) + progress.lastChapter = chapter + saveProgress(progress) + wx.navigateTo({ + url: chapterRoute(chapter, { replay }), + }) + }, + + restartStory() { + wx.showModal({ + title: '从第一回重新开始?', + content: '关卡进度会清空;已经收藏的“我的桂香岁月”和大字设置会保留。', + confirmText: '重新开始', + cancelText: '先不清空', + confirmColor: '#9f3028', + success(result) { + if (!result || !result.confirm) return + if (!resetStoryProgress()) { + wx.showToast({ + title: '进度暂时没有清空,请稍后再试', + icon: 'none', + }) + return + } + wx.redirectTo({ url: chapterRoute(1) }) + }, + }) + }, + + goBack() { + wx.reLaunch({ url: '/pages/home/home' }) + }, + + onShareAppMessage() { + return { + title: '唐侦探:翻开十五回桂香故事', + path: '/pages/catalog/catalog', + } + }, + + onShareTimeline() { + return { + title: '唐侦探:翻开十五回桂香故事', + query: '', + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/pages/catalog/catalog.json b/TongjiUniApp/native/tang-detective/pages/catalog/catalog.json new file mode 100644 index 0000000..83e7971 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/catalog/catalog.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "十五回目录", + "pageOrientation": "landscape" +} diff --git a/TongjiUniApp/native/tang-detective/pages/catalog/catalog.wxml b/TongjiUniApp/native/tang-detective/pages/catalog/catalog.wxml new file mode 100644 index 0000000..2aafb68 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/catalog/catalog.wxml @@ -0,0 +1,63 @@ + + + + + 桂香里的第十五桌 + 第一季 · 十五回目录 + 十五回目录 + + + + + + + + + + {{item.number < 10 ? '0' + item.number : item.number}} + + + + {{item.year}} + {{item.title}} + + + {{item.readingLabel}} + + + 已看见 {{item.eventCount}} 处线索 + + + + + + + 十五回故事,都在这本画册里。 + + + + {{catalogListEnded ? '已经翻到最后一回' : '向下翻,还有更多故事'}} + + + diff --git a/TongjiUniApp/native/tang-detective/pages/catalog/catalog.wxss b/TongjiUniApp/native/tang-detective/pages/catalog/catalog.wxss new file mode 100644 index 0000000..70eb6fe --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/catalog/catalog.wxss @@ -0,0 +1,364 @@ +.catalog-shell { + display: flex; + height: 100vh; + gap: 12px; + flex-direction: column; + overflow: hidden; +} + +.catalog-head { + display: grid; + flex: 0 0 auto; + min-height: 64px; + grid-template-columns: 132px minmax(0, 1fr) 132px; + align-items: center; + gap: 14px; + padding: 8px 112px 8px 14px; +} + +.restart-story-button { + width: 100%; + min-width: 0; + min-height: 48px; + padding-right: 8px; + padding-left: 8px; + color: #7b2c25; + border-color: #a85d4e; +} + +.catalog-heading-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.catalog-head > .quiet-button { + width: 100%; + min-width: 0; + min-height: 48px; + padding-right: 8px; + padding-left: 8px; +} + +.catalog-title { + overflow: hidden; + font-size: 25px; + font-weight: 850; + letter-spacing: 2px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.catalog-title-short { + display: none; +} + +.catalog-heading-copy .eyebrow { + overflow: hidden; + font-size: 16px; + letter-spacing: 1px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chapter-scroll { + min-height: 0; + flex: 1; +} + +.list-scroll-guide { + display: flex; + min-height: 40px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + gap: 10px; + color: #f4e5bd; + background: rgba(58, 40, 28, 0.94); + border: 1px solid rgba(222, 194, 139, 0.55); + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 750; +} + +.scroll-guide-mark { + color: #f0c86b; + font-size: 24px; + line-height: 1; +} + +.chapter-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + padding: 0 2px 22px; +} + +.chapter-card { + position: relative; + display: grid; + width: 100%; + min-height: 112px; + overflow: hidden; + grid-template-columns: 68px minmax(0, 1fr) 24px; + align-items: center; + gap: 14px; + padding: 12px 16px 12px 12px; + text-align: left; + border-radius: 8px; +} + +.chapter-card.completed { + border-color: #50745f; +} + +.chapter-card.current { + border: 3px solid #a23a2d; + box-shadow: 0 10px 25px rgba(94, 32, 24, 0.24); +} + +.chapter-card-hover { + transform: translateY(2px); + opacity: 0.88; +} + +.chapter-number { + display: flex; + height: 88px; + align-items: center; + justify-content: center; + flex-direction: column; + color: #f4e4be; + background: #432b1e; + border-left: 7px solid var(--cinnabar); + font-size: 16px; +} + +.chapter-number .number { + font-family: Georgia, serif; + font-size: 32px; + font-weight: 800; + line-height: 1.1; +} + +.chapter-card-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.year { + color: #9a332b; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 700; +} + +.chapter-name { + display: -webkit-box; + margin-top: 4px; + overflow: hidden; + font-size: 22px; + font-weight: 800; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.chapter-badges { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 15px; +} + +.chapter-badge { + padding: 5px 9px; + border-radius: 999px; +} + +.done-badge { + color: #eff7ef; + background: #37624f; +} + +.current-badge { + color: #fff1cf; + background: #943128; +} + +.read-badge { + color: #725c42; + background: #e5d3a9; +} + +.chapter-progress { + color: #6e5942; + font-size: 15px; + font-weight: 700; +} + +.open-arrow { + color: #8e392f; + font-size: 44px; +} + +.scroll-ending { + display: block; + padding: 4px 0 18px; + color: #d9c69e; + font-size: 17px; + text-align: center; +} + +.font-xlarge .catalog-title { + font-size: 28px; +} + +.font-xlarge .catalog-heading-copy .eyebrow { + font-size: 18px; +} + +.font-xlarge .chapter-card { + min-height: 126px; +} + +.font-xlarge .chapter-number { + height: 100px; + font-size: 18px; +} + +.font-xlarge .chapter-number .number { + font-size: 36px; +} + +.font-xlarge .year { + font-size: 19px; +} + +.font-xlarge .chapter-name { + font-size: 25px; +} + +.font-xlarge .chapter-badges, +.font-xlarge .chapter-progress { + font-size: 17px; +} + +.font-xlarge .scroll-ending { + font-size: 19px; +} + +.font-xlarge .list-scroll-guide { + font-size: 19px; +} + +@media (max-width: 730px), (max-height: 400px) { + .catalog-shell { + gap: 8px; + } + + .catalog-head { + min-height: 58px; + grid-template-columns: 96px minmax(0, 1fr) 116px; + gap: 9px; + padding: 6px 108px 6px 8px; + } + + .catalog-title { + font-size: 21px; + } + + .catalog-heading-copy .eyebrow { + display: none; + } + + .catalog-title-full { + display: none; + } + + .catalog-title-short { + display: block; + } + + .chapter-grid { + gap: 9px; + padding-bottom: 16px; + } + + .chapter-card { + min-height: 102px; + grid-template-columns: 58px minmax(0, 1fr) 20px; + gap: 10px; + padding: 9px 10px 9px 8px; + } + + .chapter-number { + height: 80px; + font-size: 14px; + } + + .chapter-number .number { + font-size: 27px; + } + + .year { + font-size: 15px; + } + + .chapter-name { + font-size: 19px; + } + + .chapter-badges, + .chapter-progress { + font-size: 13px; + } + + .open-arrow { + font-size: 36px; + } + + .list-scroll-guide { + min-height: 36px; + font-size: 16px; + } + + .scroll-guide-mark { + font-size: 21px; + } + + .font-xlarge .catalog-title { + font-size: 24px; + } + + .font-xlarge .chapter-card { + min-height: 116px; + } + + .font-xlarge .chapter-number { + height: 94px; + font-size: 16px; + } + + .font-xlarge .chapter-number .number { + font-size: 31px; + } + + .font-xlarge .year { + font-size: 17px; + } + + .font-xlarge .chapter-name { + font-size: 22px; + } + + .font-xlarge .chapter-badges, + .font-xlarge .chapter-progress { + font-size: 15px; + } +} diff --git a/TongjiUniApp/native/tang-detective/pages/home/home.js b/TongjiUniApp/native/tang-detective/pages/home/home.js new file mode 100644 index 0000000..442a655 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/home/home.js @@ -0,0 +1,121 @@ +const memoryCards = require('../../data/memoryCards') +const { getProgress } = require('../../utils/storage') +const { getCollectedMemories } = require('../../utils/memoryCollection') +const { chapterRoute } = require('../../utils/chapterRoute') +const { + getHomeLayoutMetrics, + getHomeLayoutStyle, + hasReadingProgress, +} = require('./homeLayout') + +Page({ + data: { + coverOpened: false, + completedCount: 0, + lastChapter: 1, + hasReadingProgress: false, + memoryCount: 0, + homeLayoutStyle: [ + 'height:390px', + '--home-top-inset:8px', + '--home-right-inset:10px', + '--home-bottom-inset:7px', + '--home-left-inset:10px', + ].join(';'), + }, + + onLoad() { + this.applyHomeLayout() + }, + + onShow() { + const progress = getProgress() + this.setData({ + completedCount: progress.completedChapters.length, + lastChapter: progress.lastChapter || 1, + hasReadingProgress: hasReadingProgress(progress), + memoryCount: getCollectedMemories(progress, memoryCards).length, + }) + this.applyHomeLayout() + }, + + onResize(resizeInfo) { + this.applyHomeLayout(resizeInfo) + }, + + applyHomeLayout(resizeInfo = null) { + let windowInfo = {} + try { + windowInfo = wx.getWindowInfo + ? wx.getWindowInfo() + : wx.getSystemInfoSync() + } catch (error) { + windowInfo = {} + } + + const resizeSize = resizeInfo && resizeInfo.size + ? resizeInfo.size + : resizeInfo + if (resizeSize) { + windowInfo = { + ...windowInfo, + windowWidth: resizeSize.windowWidth || windowInfo.windowWidth, + windowHeight: resizeSize.windowHeight || windowInfo.windowHeight, + } + } + + let menuRect = {} + try { + menuRect = wx.getMenuButtonBoundingClientRect + ? wx.getMenuButtonBoundingClientRect() + : {} + } catch (error) { + menuRect = {} + } + + const metrics = getHomeLayoutMetrics(windowInfo, menuRect) + this.setData({ + homeLayoutStyle: getHomeLayoutStyle(metrics), + }) + }, + + startGame() { + const chapter = this.data.lastChapter || 1 + wx.navigateTo({ + url: chapterRoute(chapter), + }) + }, + + revealCover() { + if (this.data.coverOpened) return + this.setData({ coverOpened: true }) + }, + + keepCoverOpen() {}, + + openCatalog() { + wx.navigateTo({ url: '/pages/catalog/catalog' }) + }, + + openCast() { + wx.navigateTo({ url: '/pages/cast/cast' }) + }, + + openMemories() { + wx.navigateTo({ url: '/pages/memories/memories' }) + }, + + onShareAppMessage() { + return { + title: '唐侦探:在一桌饭里,看见被忽略的人', + path: '/pages/home/home', + } + }, + + onShareTimeline() { + return { + title: '唐侦探:在一桌饭里,看见被忽略的人', + query: '', + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/pages/home/home.json b/TongjiUniApp/native/tang-detective/pages/home/home.json new file mode 100644 index 0000000..5e7aafd --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/home/home.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "唐侦探", + "pageOrientation": "landscape" +} diff --git a/TongjiUniApp/native/tang-detective/pages/home/home.wxml b/TongjiUniApp/native/tang-detective/pages/home/home.wxml new file mode 100644 index 0000000..f0b8388 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/home/home.wxml @@ -0,0 +1,65 @@ + + + + + + 甄养堂 · 中国健康连环画 + + 第一季 + 唐侦探 + 桂香里的第十五桌 + + + + 翻开瞧瞧 + + + + + + + + 甄养堂 + 一本能看、能点、能带回家聊的中国健康连环画 + + + + 桂香里的第十五桌 + 第一季 · 一桌饭里的三代人 + “一桌饭,不应该只有坐下的人,还应该有被看见的人。” + + + + + + + + + + 先看画、听故事;翻到背面,再聊聊这回事。 + + + + 这里聊的是日常生活,不替代诊断、处方或个体化治疗建议。 + diff --git a/TongjiUniApp/native/tang-detective/pages/home/home.wxss b/TongjiUniApp/native/tang-detective/pages/home/home.wxss new file mode 100644 index 0000000..8d649fd --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/home/home.wxss @@ -0,0 +1,678 @@ +.home-shell { + --home-top-inset: calc(8px + constant(safe-area-inset-top)); + --home-right-inset: calc(10px + constant(safe-area-inset-right)); + --home-bottom-inset: calc(7px + constant(safe-area-inset-bottom)); + --home-left-inset: calc(10px + constant(safe-area-inset-left)); + --home-top-inset: calc(8px + env(safe-area-inset-top)); + --home-right-inset: calc(10px + env(safe-area-inset-right)); + --home-bottom-inset: calc(7px + env(safe-area-inset-bottom)); + --home-left-inset: calc(10px + env(safe-area-inset-left)); + display: flex; + height: 100vh; + min-height: 0; + padding: var(--home-top-inset) var(--home-right-inset) + var(--home-bottom-inset) var(--home-left-inset); + gap: 6px; + overflow: hidden; + flex-direction: column; +} + +.home-comic-book { + position: relative; + display: grid; + width: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + color: #33251b; + background: #1d130f; + border: 3px solid #9e845e; + border-radius: 10px; + box-shadow: 0 15px 34px rgba(0, 0, 0, 0.36); + grid-template-columns: minmax(0, 1fr); +} + +.home-comic-book.is-open { + grid-template-columns: minmax(0, 1.45fr) minmax(280px, 1fr); +} + +.home-cover-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #241811; +} + +.home-comic-book.is-open .home-cover-art { + border-right: 7px solid #6f2b23; + box-shadow: 10px 0 24px rgba(32, 19, 12, 0.34); +} + +.home-cover-image, +.home-cover-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.home-cover-shade { + pointer-events: none; + background: + linear-gradient(90deg, rgba(29, 18, 12, 0.68), transparent 24%, transparent 72%, rgba(29, 18, 12, 0.2)), + linear-gradient(0deg, rgba(31, 18, 12, 0.82), transparent 53%); + box-shadow: inset 0 0 70px rgba(30, 16, 9, 0.3); +} + +.home-cover-spine { + position: absolute; + z-index: 3; + top: 0; + bottom: 0; + left: 0; + display: flex; + width: 46px; + align-items: center; + justify-content: center; + color: #f0d8a7; + background: rgba(74, 35, 26, 0.94); + border-right: 2px solid #c59a58; + font-family: "Songti SC", "STSong", serif; + font-size: 15px; + font-weight: 850; + letter-spacing: 3px; + writing-mode: vertical-rl; +} + +.home-cover-title-block { + position: absolute; + z-index: 3; + left: 72px; + bottom: 54px; + display: flex; + max-width: 64%; + padding: 13px 19px 15px; + color: #f7e7c4; + background: rgba(43, 27, 18, 0.86); + border-left: 7px solid #a53a30; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + flex-direction: column; +} + +.home-cover-series { + color: #e4c277; + font-size: 16px; + font-weight: 800; + letter-spacing: 4px; +} + +.home-cover-title { + margin-top: 3px; + font-family: "Songti SC", "STSong", serif; + font-size: 43px; + font-weight: 900; + letter-spacing: 8px; + line-height: 1.05; +} + +.home-cover-subtitle { + margin-top: 6px; + color: #f1d18c; + font-family: "Songti SC", "STSong", serif; + font-size: 25px; + font-weight: 900; + line-height: 1.2; +} + +.home-cover-touch { + position: absolute; + z-index: 4; + right: 24px; + bottom: 22px; + display: flex; + min-height: 50px; + align-items: center; + gap: 9px; + padding: 8px 15px; + color: #fff0ca; + background: rgba(54, 34, 23, 0.9); + border: 2px solid #d7b36d; + border-radius: 999px; + font-size: 18px; + font-weight: 850; + box-shadow: 0 7px 18px rgba(0, 0, 0, 0.28); +} + +.home-cover-touch-mark { + display: flex; + width: 31px; + height: 31px; + align-items: center; + justify-content: center; + color: #6c271f; + background: #f0d28c; + border-radius: 50%; + font-size: 28px; + line-height: 1; +} + +.home-flyleaf { + display: flex; + box-sizing: border-box; + min-width: 0; + min-height: 0; + justify-content: center; + overflow: hidden; + padding: 16px 20px; + background: + repeating-linear-gradient(0deg, rgba(92, 61, 35, 0.03) 0, rgba(92, 61, 35, 0.03) 1px, transparent 1px, transparent 5px), + #f2e4bf; + flex-direction: column; +} + +.home-flyleaf-title { + display: block; + margin-top: 9px; + color: #8d2d26; + font-family: "Songti SC", "STSong", serif; + font-size: 29px; + font-weight: 900; + line-height: 1.18; +} + +.home-flyleaf .home-quote { + position: static; + display: block; + margin-top: 10px; + color: #4e3a2b; + font-size: 18px; + font-weight: 750; + line-height: 1.45; +} + +.home-flyleaf .home-primary { + margin-top: 12px; +} + +.home-flyleaf .home-tabs { + width: 100%; + min-width: 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.home-flyleaf .home-tab { + box-sizing: border-box; + width: 100%; + min-width: 0; + overflow: hidden; + padding: 5px 7px; + font-size: 16px; + white-space: nowrap; +} + +.home-comic-book button::after { + border: 0; +} + +.home-book { + display: grid; + width: 100%; + min-height: 0; + flex: 1; + overflow: hidden; + grid-template-columns: minmax(0, 3fr) minmax(285px, 2fr); + border: 2px solid #9e845e; + border-radius: 9px; +} + +.home-art { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px), + #241811; + border-right: 5px solid var(--cinnabar); +} + +.home-art-image, +.home-art-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.home-art-shade { + pointer-events: none; + background: linear-gradient(0deg, rgba(35, 23, 16, 0.82), transparent 48%); +} + +.home-memory-ribbon { + position: absolute; + z-index: 3; + top: 12px; + left: 12px; + display: flex; + min-height: 48px; + align-items: center; + gap: 7px; + padding: 6px 10px 6px 7px; + color: #4a3225; + background: rgba(241, 221, 178, 0.96); + border: 2px solid #9e754a; + border-radius: 5px; + box-shadow: 0 5px 14px rgba(25, 15, 8, 0.28); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; + white-space: nowrap; +} + +.home-memory-ribbon-mark { + display: flex; + width: 28px; + height: 35px; + align-items: center; + justify-content: center; + color: #ffe9bb; + background: #963128; + clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 78%, 0 100%); + font-size: 14px; + font-weight: 900; +} + +.home-quote { + position: absolute; + z-index: 2; + right: 22px; + bottom: 18px; + left: 22px; + color: #f5e5c0; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 20px; + font-weight: 800; + line-height: 1.5; +} + +.home-copy { + display: flex; + box-sizing: border-box; + min-width: 0; + min-height: 0; + justify-content: center; + overflow-x: hidden; + overflow-y: auto; + padding: 18px 24px; + flex-direction: column; +} + +.home-brand { + display: flex; + align-items: center; + gap: 10px; +} + +.home-brand .seal { + width: 48px; + height: 48px; + flex: none; + font-size: 27px; +} + +.home-brand-copy { + display: flex; + color: #7f2a23; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; + line-height: 1.35; +} + +.home-brand-kind { + color: #765f45; + font-size: 16px; + font-weight: 700; +} + +.home-title { + display: block; + margin-top: 10px; + font-size: 45px; + font-weight: 900; + letter-spacing: 7px; + line-height: 1.05; +} + +.home-subtitle { + display: block; + margin-top: 4px; + color: #8d2d26; + font-size: 30px; + font-weight: 900; + line-height: 1.2; +} + +.home-season { + display: block; + margin-top: 5px; + color: #6f5942; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 750; +} + +.home-primary { + display: flex; + width: 100%; + min-height: 64px; + align-items: center; + justify-content: center; + margin-top: 16px; + padding: 8px 14px; + color: #fff0cb; + background: var(--cinnabar); + border: 3px solid #74231d; + border-radius: 8px; + box-shadow: 0 8px 18px rgba(111, 32, 27, 0.24); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 21px; + font-weight: 900; +} + +.home-tabs { + display: grid; + gap: 9px; + margin-top: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.home-tab { + position: relative; + display: flex; + min-width: 0; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 6px 10px; + color: #4b3829; + background: #ead9b2; + border: 2px solid #997b54; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.home-memory-count { + display: flex; + min-width: 30px; + height: 26px; + align-items: center; + justify-content: center; + padding: 0 6px; + color: #f9edce; + background: #704b35; + border-radius: 999px; + font-size: 13px; +} + +.home-tagline { + display: block; + margin-top: 10px; + color: #594431; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 700; + line-height: 1.48; +} + +.home-disclaimer { + display: block; + flex: none; + color: #d7c49b; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + line-height: 1.35; + text-align: center; +} + +@media (max-height: 620px) { + .home-copy { + justify-content: flex-start; + overflow-y: hidden; + padding: 12px 18px; + } + + .home-brand .seal { + width: 42px; + height: 42px; + font-size: 24px; + } + + .home-brand-copy { + font-size: 17px; + } + + .home-brand-kind { + font-size: 15px; + } + + .home-title { + margin-top: 7px; + font-size: 39px; + } + + .home-subtitle { + font-size: 27px; + } + + .home-season { + font-size: 17px; + } + + .home-primary { + min-height: 60px; + margin-top: 11px; + font-size: 20px; + } + + .home-tabs { + margin-top: 8px; + } + + .home-tab { + min-height: 48px; + font-size: 17px; + } + + .home-tagline { + display: block; + margin-top: 6px; + font-size: 16px; + line-height: 1.35; + } + + .home-disclaimer { + font-size: 16px; + } +} + +@media (max-width: 730px) { + .home-brand-kind { + display: none; + } + + .home-brand { + max-width: calc(100% - 104px); + } + + .home-title { + font-size: 39px; + letter-spacing: 5px; + } + + .home-subtitle { + font-size: 26px; + } + + .home-tagline { + display: block; + margin-top: 6px; + font-size: 16px; + line-height: 1.35; + } + + .home-memory-ribbon { + top: 9px; + left: 9px; + min-height: 48px; + padding: 5px 8px 5px 6px; + font-size: 16px; + } +} + +@media (max-height: 430px) { + .home-comic-book.is-open { + grid-template-columns: minmax(0, 1.3fr) minmax(292px, 1fr); + } + + .home-cover-title-block { + left: 62px; + bottom: 34px; + padding: 9px 13px 10px; + } + + .home-cover-series { + font-size: 13px; + } + + .home-cover-title { + font-size: 34px; + letter-spacing: 6px; + } + + .home-cover-subtitle { + font-size: 20px; + } + + .home-cover-touch { + right: 16px; + bottom: 13px; + min-height: 48px; + font-size: 16px; + } + + .home-flyleaf { + padding: 8px 12px; + } + + .home-flyleaf .home-brand .seal { + width: 34px; + height: 34px; + font-size: 20px; + } + + .home-flyleaf .home-brand-copy { + font-size: 14px; + } + + .home-flyleaf-title { + margin-top: 5px; + font-size: 23px; + } + + .home-flyleaf .home-season { + font-size: 14px; + } + + .home-flyleaf .home-quote { + margin-top: 5px; + font-size: 15px; + line-height: 1.3; + } + + .home-flyleaf .home-primary { + min-height: 50px; + margin-top: 6px; + font-size: 18px; + } + + .home-flyleaf .home-tabs { + gap: 5px; + margin-top: 5px; + } + + .home-flyleaf .home-tab { + min-height: 48px; + padding: 4px; + font-size: 14px; + } + + .home-flyleaf .home-tagline { + margin-top: 4px; + font-size: 14px; + } + + .home-copy { + justify-content: flex-start; + overflow-y: hidden; + padding: 8px 14px; + } + + .home-brand .seal { + width: 36px; + height: 36px; + font-size: 22px; + } + + .home-brand-copy { + font-size: 15px; + } + + .home-brand-kind { + font-size: 13px; + } + + .home-title { + margin-top: 4px; + font-size: 34px; + letter-spacing: 4px; + } + + .home-subtitle { + margin-top: 2px; + font-size: 23px; + } + + .home-season { + margin-top: 3px; + font-size: 15px; + } + + .home-primary { + min-height: 60px; + margin-top: 7px; + padding: 6px 10px; + font-size: 19px; + } + + .home-tabs { + margin-top: 5px; + } + + .home-tagline { + display: block; + margin-top: 4px; + font-size: 15px; + line-height: 1.2; + } + + .home-disclaimer { + font-size: 14px; + line-height: 1.2; + } +} diff --git a/TongjiUniApp/native/tang-detective/pages/home/homeLayout.js b/TongjiUniApp/native/tang-detective/pages/home/homeLayout.js new file mode 100644 index 0000000..ee2713c --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/home/homeLayout.js @@ -0,0 +1,122 @@ +function finiteNumber(value, fallback = 0) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)) +} + +/** + * 首页使用自定义导航栏,横屏时既要避开左右刘海与底部 Home + * Indicator,也要为微信右上角胶囊留下真实空间。CSS 的 env() + * 在部分微信横屏机型上只会返回系统安全区,不包含胶囊,因此这里 + * 以运行时尺寸为准,CSS env() 只作为脚本尚未执行时的兜底。 + */ +function getHomeLayoutMetrics(windowInfo = {}, menuRect = {}) { + const windowWidth = Math.max(320, finiteNumber(windowInfo.windowWidth, 844)) + const windowHeight = Math.max(240, finiteNumber(windowInfo.windowHeight, 390)) + const safeArea = windowInfo.safeArea || {} + + const safeLeft = clamp( + finiteNumber(safeArea.left, 0), + 0, + windowWidth / 3, + ) + const safeRightEdge = clamp( + finiteNumber(safeArea.right, windowWidth), + windowWidth * 2 / 3, + windowWidth, + ) + const safeRight = clamp( + windowWidth - safeRightEdge, + 0, + windowWidth / 3, + ) + const safeTop = Math.max( + 0, + finiteNumber(windowInfo.statusBarHeight, 0), + finiteNumber(safeArea.top, 0), + ) + const safeBottomEdge = clamp( + finiteNumber(safeArea.bottom, windowHeight), + windowHeight * 2 / 3, + windowHeight, + ) + const safeBottom = clamp( + windowHeight - safeBottomEdge, + 0, + windowHeight / 3, + ) + + const menuLeft = finiteNumber(menuRect.left, windowWidth) + const menuBottom = finiteNumber(menuRect.bottom, 0) + const hasMenuRect = ( + menuLeft > windowWidth / 2 + && menuLeft < windowWidth + && menuBottom > 0 + ) + const compactHeight = windowHeight <= 620 + const horizontalPageGap = compactHeight ? 10 : 14 + const capsuleGap = compactHeight ? 8 : 10 + + const leftInset = Math.ceil(safeLeft + horizontalPageGap) + const rightInset = Math.ceil(Math.max( + safeRight + horizontalPageGap, + hasMenuRect + ? windowWidth - menuLeft + capsuleGap + : safeRight + horizontalPageGap, + )) + const topInset = Math.ceil(safeTop + (compactHeight ? 8 : 12)) + const bottomInset = Math.ceil(safeBottom + (compactHeight ? 7 : 12)) + + return { + windowWidth, + windowHeight, + compactHeight, + safeLeft, + safeRight, + safeTop, + safeBottom, + leftInset, + rightInset, + topInset, + bottomInset, + hasMenuRect, + } +} + +function getHomeLayoutStyle(metrics = {}) { + return [ + `height:${Math.max(240, finiteNumber(metrics.windowHeight, 390))}px`, + `--home-top-inset:${Math.max(0, finiteNumber(metrics.topInset, 8))}px`, + `--home-right-inset:${Math.max(0, finiteNumber(metrics.rightInset, 10))}px`, + `--home-bottom-inset:${Math.max(0, finiteNumber(metrics.bottomInset, 7))}px`, + `--home-left-inset:${Math.max(0, finiteNumber(metrics.leftInset, 10))}px`, + ].join(';') +} + +function hasReadingProgress(progress = {}) { + const completedChapters = Array.isArray(progress.completedChapters) + ? progress.completedChapters + : [] + const completedHotspots = progress.completedHotspots + && typeof progress.completedHotspots === 'object' + ? progress.completedHotspots + : {} + const hasCompletedHotspot = Object.values(completedHotspots).some( + (ids) => Array.isArray(ids) && ids.length > 0, + ) + + return ( + completedChapters.length > 0 + || finiteNumber(progress.lastChapter, 1) > 1 + || hasCompletedHotspot + ) +} + +module.exports = { + getHomeLayoutMetrics, + getHomeLayoutStyle, + hasReadingProgress, +} diff --git a/TongjiUniApp/native/tang-detective/pages/memories/memories.js b/TongjiUniApp/native/tang-detective/pages/memories/memories.js new file mode 100644 index 0000000..452c837 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/memories/memories.js @@ -0,0 +1,77 @@ +const memoryCards = require('../../data/memoryCards') +const { + getProgress, + getSettings, + saveSettings, +} = require('../../utils/storage') +const { + getCollectedMemories, +} = require('../../utils/memoryCollection') +const { chapterRoute } = require('../../utils/chapterRoute') + +function twoDigits(value) { + return String(value).padStart(2, '0') +} + +Page({ + data: { + memories: [], + memoryCount: 0, + fontScale: 'large', + }, + + onShow() { + const progress = getProgress() + const settings = getSettings() + const memories = getCollectedMemories(progress, memoryCards).map( + (memory, index) => ({ + ...memory, + chapterLabel: `第${twoDigits(memory.chapterNumber)}回`, + pageLabel: `第${index + 1}页`, + characterInitial: memory.characterName.slice(0, 1), + }), + ) + this.setData({ + memories, + memoryCount: memories.length, + fontScale: settings.fontScale === 'xlarge' ? 'xlarge' : 'large', + }) + }, + + goBack() { + wx.reLaunch({ url: '/pages/home/home' }) + }, + + openCatalog() { + wx.navigateTo({ url: '/pages/catalog/catalog' }) + }, + + openChapter(event) { + const chapter = Number(event.currentTarget.dataset.chapter) || 1 + wx.navigateTo({ + url: chapterRoute(chapter), + }) + }, + + toggleFont() { + const fontScale = this.data.fontScale === 'large' ? 'xlarge' : 'large' + const settings = getSettings() + settings.fontScale = fontScale + saveSettings(settings) + this.setData({ fontScale }) + }, + + onShareAppMessage() { + return { + title: '我的桂香岁月:饭桌边,也有值得带回家说的话', + path: '/pages/home/home', + } + }, + + onShareTimeline() { + return { + title: '我的桂香岁月:饭桌边,也有值得带回家说的话', + query: '', + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/pages/memories/memories.json b/TongjiUniApp/native/tang-detective/pages/memories/memories.json new file mode 100644 index 0000000..b8be3f9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/memories/memories.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/pages/memories/memories.wxml b/TongjiUniApp/native/tang-detective/pages/memories/memories.wxml new file mode 100644 index 0000000..ea00094 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/memories/memories.wxml @@ -0,0 +1,91 @@ + + + + + + 我的桂香岁月 + + {{memoryCount > 0 ? '收下的故事,慢慢翻、慢慢聊' : '一本等你慢慢装满的故事册'}} + + + + + + + + + + 桂香 + + 这本册子,还等着第一段回忆 + 每读完一回,把最后那一页“收进画册”,这里就会多一段人物、一件旧物和一句带回家聊的话。 + + + + + + + + + + {{item.pageLabel}} + {{item.characterInitial}} + {{item.characterName}} + {{item.eraLine}} + + 这一回留下的物件 + {{item.eraObject || '桂香饭桌边的一件旧物'}} + + + + + {{item.chapterLabel}} · {{item.chapterTitle}} + “{{item.tableEcho}}” + + 今天可以这样做 + {{item.lifeAction}} + + + 带回家聊一聊 + “{{item.familyLine}}” + + + + + + 往后的页,还在饭桌边等你 + 每收下一页,不是为了攒数字,只为记住一个被看见的人。 + + + + + + diff --git a/TongjiUniApp/native/tang-detective/pages/memories/memories.wxss b/TongjiUniApp/native/tang-detective/pages/memories/memories.wxss new file mode 100644 index 0000000..bc67fe6 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/memories/memories.wxss @@ -0,0 +1,568 @@ +/* Personal storybook: horizontal pages with elder-friendly reading controls. */ +.memories-shell { + height: 100vh; + min-height: 0; + overflow: hidden; +} + +.memories-book { + display: flex; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + border-radius: 9px; + flex-direction: column; +} + +.memories-head { + position: relative; + display: flex; + min-height: 66px; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + padding: 8px 10px 8px 12px; + border-bottom: 3px solid #9b332a; +} + +.memory-back, +.memory-font, +.empty-action, +.memory-reread, +.memory-end-action { + display: flex; + min-height: 48px; + align-items: center; + justify-content: center; + padding: 6px 13px; + border: 2px solid #997a54; + border-radius: 7px; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 850; +} + +.memory-back, +.memory-font { + color: #52392a; + background: #ead8af; +} + +.memories-heading { + position: absolute; + z-index: 1; + top: 8px; + right: 246px; + left: 134px; + display: flex; + min-width: 0; + align-items: center; + pointer-events: none; + flex-direction: column; +} + +.memories-title { + overflow: hidden; + font-size: 29px; + font-weight: 900; + letter-spacing: 3px; + line-height: 1.16; + text-overflow: ellipsis; + white-space: nowrap; +} + +.memories-subtitle { + overflow: hidden; + color: #725b43; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.capsule-safe-space { + width: 102px; + height: 48px; + flex: 0 0 102px; +} + +.memories-head-actions { + position: absolute; + z-index: 2; + top: 8px; + right: 10px; + display: flex; + align-items: center; + gap: 10px; +} + +.memory-font { + min-width: 112px; + white-space: nowrap; +} + +.memory-back-short, +.memory-font-short { + display: none; +} + +.memory-back { + position: absolute; + z-index: 2; + top: 8px; + left: 12px; + width: 112px; + min-width: 112px; +} + +.empty-memory { + display: grid; + width: calc(100% - 48px); + max-width: 680px; + min-height: 230px; + align-self: center; + grid-template-columns: 132px minmax(0, 1fr); + align-items: stretch; + margin: auto; + overflow: hidden; + background: #f8ecc9; + border: 3px double #97784f; + border-radius: 6px; + box-shadow: 0 12px 28px rgba(67, 40, 23, 0.18); +} + +.empty-bookmark { + display: flex; + align-items: center; + justify-content: center; + color: #f8e7bb; + background: #913128; + font-size: 30px; + font-weight: 900; + letter-spacing: 7px; + writing-mode: vertical-rl; +} + +.empty-copy { + display: flex; + justify-content: center; + padding: 22px 28px; + flex-direction: column; +} + +.empty-title { + font-size: 27px; + font-weight: 900; +} + +.empty-text { + margin-top: 9px; + color: #604b36; + font-size: 18px; + font-weight: 650; + line-height: 1.55; +} + +.empty-action { + width: 190px; + margin-top: 14px; + color: #fff0cc; + background: #943128; + border-color: #74231d; +} + +.memory-scroll { + width: 100%; + min-height: 0; + flex: 1; + padding: 12px 0 10px; +} + +.memory-pages { + display: inline-flex; + min-width: 100%; + height: 100%; + align-items: stretch; + gap: 16px; + padding: 0 18px; +} + +.memory-sheet { + position: relative; + display: grid; + width: 710px; + height: 100%; + min-height: 0; + flex: none; + overflow: hidden; + grid-template-columns: 225px minmax(0, 1fr); + background: + repeating-linear-gradient(0deg, rgba(84, 55, 31, 0.025) 0, rgba(84, 55, 31, 0.025) 1px, transparent 1px, transparent 6px), + #f8edcc; + border: 3px double #91724b; + border-radius: 6px; + box-shadow: 0 10px 24px rgba(58, 35, 21, 0.2); + scroll-snap-align: center; +} + +.memory-sheet-spine { + position: absolute; + z-index: 2; + top: 0; + bottom: 0; + left: 219px; + width: 7px; + pointer-events: none; + background: linear-gradient(90deg, rgba(81, 50, 27, 0.1), rgba(255, 255, 255, 0.58), rgba(81, 50, 27, 0.12)); +} + +.memory-left { + position: relative; + display: flex; + min-width: 0; + align-items: center; + padding: 17px 20px; + background: #263a3e; + flex-direction: column; + color: #f5e5bd; +} + +.memory-page-number { + align-self: flex-start; + color: #d6ba7e; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 750; +} + +.memory-person-seal { + display: flex; + width: 74px; + height: 74px; + align-items: center; + justify-content: center; + margin-top: 4px; + color: #8d2d25; + background: #efe0b8; + border: 5px double #ad4d3a; + border-radius: 50%; + font-size: 37px; + font-weight: 900; +} + +.memory-person { + margin-top: 5px; + font-size: 25px; + font-weight: 900; +} + +.memory-era { + margin-top: 4px; + color: #e3cf9f; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 650; + line-height: 1.4; + text-align: center; +} + +.memory-object { + display: flex; + width: 100%; + margin-top: auto; + padding: 8px 10px; + color: #f5e5bd; + background: rgba(0, 0, 0, 0.18); + border-left: 4px solid #caa65e; + flex-direction: column; + font-size: 17px; + font-weight: 750; + line-height: 1.4; +} + +.memory-right { + min-width: 0; + height: 100%; + padding: 14px 22px 16px 25px; +} + +.memory-chapter { + display: block; + color: #8d2f27; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 17px; + font-weight: 850; +} + +.memory-echo { + display: block; + margin-top: 7px; + padding-bottom: 7px; + font-size: 23px; + font-weight: 900; + line-height: 1.45; + border-bottom: 1px solid #c9af7f; +} + +.memory-note { + display: flex; + margin-top: 8px; + color: #4f3c2b; + font-size: 17px; + font-weight: 700; + line-height: 1.42; + flex-direction: column; +} + +.memory-label { + display: block; + margin-bottom: 2px; + color: #a03b31; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; + font-size: 15px; + font-weight: 850; +} + +.family-note { + padding-left: 10px; + border-left: 4px solid #35634f; +} + +.memory-reread { + width: 156px; + margin-top: 10px; + color: #fff0cc; + background: #8d3028; + border-color: #70231e; +} + +.memory-end-card { + display: flex; + width: 310px; + height: 100%; + min-height: 0; + flex: none; + justify-content: center; + padding: 24px; + color: #f2e2b8; + background: #3a2a20; + border: 3px double #ad8d5e; + border-radius: 6px; + flex-direction: column; + font-size: 18px; + font-weight: 650; + line-height: 1.55; +} + +.memory-end-title { + margin-bottom: 10px; + font-size: 26px; + font-weight: 900; +} + +.memory-end-action { + width: 170px; + margin-top: 18px; + color: #34251c; + background: #e9d6a8; +} + +.font-xlarge .memory-echo { + font-size: 26px; +} + +.font-xlarge .memory-note, +.font-xlarge .memory-object, +.font-xlarge .memory-end-card { + font-size: 19px; +} + +@media (max-width: 730px), (max-height: 400px) { + .memories-head { + min-height: 58px; + padding: 5px 4px; + } + + .memories-heading { + top: 17px; + right: 158px; + left: 82px; + } + + .memory-back, + .memory-font, + .empty-action, + .memory-reread, + .memory-end-action { + min-height: 48px; + padding-right: 8px; + padding-left: 8px; + font-size: 16px; + } + + .memories-title { + font-size: 18px; + letter-spacing: 0; + } + + .memories-subtitle { + display: none; + } + + .capsule-safe-space { + width: 76px; + flex-basis: 76px; + } + + .memory-font { + width: 72px; + min-width: 72px; + padding-right: 4px; + padding-left: 4px; + } + + .memory-back { + top: 5px; + left: 4px; + width: 74px; + min-width: 0; + padding-right: 4px; + padding-left: 4px; + } + + .memories-head-actions { + top: 5px; + right: 4px; + gap: 4px; + } + + .memory-back-long, + .memory-font-long { + display: none; + } + + .memory-back-short, + .memory-font-short { + display: inline; + } + + .memory-scroll { + padding-top: 8px; + padding-bottom: 8px; + } + + .memory-pages { + gap: 12px; + padding: 0 10px; + } + + .memory-sheet { + width: 565px; + grid-template-columns: 170px minmax(0, 1fr); + } + + .memory-sheet-spine { + left: 164px; + } + + .memory-left { + padding: 10px 12px; + } + + .memory-page-number { + font-size: 14px; + } + + .memory-person-seal { + width: 58px; + height: 58px; + margin-top: 1px; + font-size: 29px; + } + + .memory-person { + margin-top: 2px; + font-size: 21px; + } + + .memory-era { + font-size: 14px; + line-height: 1.3; + } + + .memory-object { + padding: 6px 8px; + font-size: 15px; + } + + .memory-right { + padding: 10px 14px 12px 18px; + } + + .memory-chapter { + font-size: 15px; + } + + .memory-echo { + margin-top: 4px; + padding-bottom: 5px; + font-size: 20px; + line-height: 1.32; + } + + .memory-note { + margin-top: 5px; + font-size: 15.5px; + line-height: 1.32; + } + + .memory-label { + font-size: 14px; + } + + .memory-reread { + width: 138px; + margin-top: 7px; + } + + .font-xlarge .memory-echo { + font-size: 22px; + } + + .font-xlarge .memory-note, + .font-xlarge .memory-object, + .font-xlarge .memory-end-card { + font-size: 17px; + } + + .empty-memory { + width: calc(100% - 24px); + max-width: 590px; + min-height: 220px; + grid-template-columns: 100px minmax(0, 1fr); + } + + .empty-bookmark { + font-size: 25px; + } + + .empty-copy { + padding: 15px 20px; + } + + .empty-title { + font-size: 23px; + } + + .empty-text { + margin-top: 5px; + font-size: 16px; + line-height: 1.42; + } + + .empty-action { + margin-top: 8px; + } +} diff --git a/TongjiUniApp/native/tang-detective/pages/report/report.js b/TongjiUniApp/native/tang-detective/pages/report/report.js new file mode 100644 index 0000000..375179f --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/report/report.js @@ -0,0 +1,85 @@ +const memoryCards = require('../../data/memoryCards') +const { chapterRoute } = require('../../utils/chapterRoute') + +const MEMORY_CARD_ID_PATTERN = /^S\d{2}-C\d{2}-MC\d{2}$/ +const INVALID_CARD_MESSAGE = '这回生活观察暂时没有找到。回到封面,还可以慢慢翻一回。' + +function normalizeCardId(value) { + const raw = String(value || '').trim() + if (!raw || raw.length > 64) return '' + let decoded = raw + try { + decoded = decodeURIComponent(raw) + } catch (error) { + return '' + } + return MEMORY_CARD_ID_PATTERN.test(decoded) ? decoded : '' +} + +function findReportCard(value) { + const cardId = normalizeCardId(value) + if (!cardId) return null + return memoryCards.find((card) => card.cardId === cardId) || null +} + +function twoDigits(value) { + return String(value).padStart(2, '0') +} + +Page({ + data: { + reportCard: null, + chapterLabel: '', + invalidCard: false, + invalidCardMessage: INVALID_CARD_MESSAGE, + }, + + onLoad(options = {}) { + const reportCard = findReportCard(options.cardId) + if (!reportCard) { + this.setData({ + reportCard: null, + chapterLabel: '', + invalidCard: true, + }) + return + } + this.setData({ + reportCard: { ...reportCard }, + chapterLabel: `第${twoDigits(reportCard.chapterNumber)}回`, + invalidCard: false, + }) + }, + + backToChapter() { + const reportCard = this.data.reportCard + if (!reportCard || !reportCard.chapterNumber) { + this.goHome() + return + } + const pages = typeof getCurrentPages === 'function' + ? getCurrentPages() + : [] + const previous = pages[pages.length - 2] + if ( + previous + && String(previous.route || '').endsWith('/pages/chapter/chapter') + && typeof wx.navigateBack === 'function' + ) { + wx.navigateBack({ delta: 1 }) + return + } + wx.navigateTo({ + url: chapterRoute(reportCard.chapterNumber), + }) + }, + + goHome() { + const target = { url: '/pages/home/home' } + if (typeof wx.reLaunch === 'function') { + wx.reLaunch(target) + return + } + wx.redirectTo(target) + }, +}) diff --git a/TongjiUniApp/native/tang-detective/pages/report/report.json b/TongjiUniApp/native/tang-detective/pages/report/report.json new file mode 100644 index 0000000..b8be3f9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/report/report.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/pages/report/report.wxml b/TongjiUniApp/native/tang-detective/pages/report/report.wxml new file mode 100644 index 0000000..174980a --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/report/report.wxml @@ -0,0 +1,81 @@ + + + + 桂香生活观察 + + {{chapterLabel}} + {{reportCard.chapterTitle}} + + 这回人物 + {{reportCard.characterName}} + + + 这页只留在您的手机里。 + 不会发送答题记录,也不用于诊断。 + + + + + + + 这一回,咱们看见了什么 + 本回生活观察,不是个人健康评估,也不替代诊断和个体化治疗 + + + + + + 本回看见 + {{reportCard.observation}} + + + + + + 家里可以 + {{reportCard.familySupport}} + + + + + + 今天试试 + {{reportCard.todayAction}} + + + + + + + + + + + + + + + 这回观察,暂时没有翻开 + {{invalidCardMessage}} + + + diff --git a/TongjiUniApp/native/tang-detective/pages/report/report.wxss b/TongjiUniApp/native/tang-detective/pages/report/report.wxss new file mode 100644 index 0000000..a87d53d --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/report/report.wxss @@ -0,0 +1,28 @@ +.report-shell{height:100vh;min-height:0;overflow:hidden} +.report-book{display:grid;width:100%;height:100%;overflow:hidden;grid-template-columns:minmax(210px,2fr) minmax(0,5fr);border:2px solid #9e845e;border-radius:9px} +.report-identity{display:flex;min-height:0;align-items:center;padding:15px 18px;overflow:hidden;color:#f5e5bd;background:#263a3e;border-right:5px solid #9b332a;flex-direction:column} +.report-kicker{align-self:flex-start;color:#dfc486;font-size:17px;font-weight:850} +.report-seal,.report-invalid-seal{display:flex;align-items:center;justify-content:center;color:#8d2d25;background:#efe0b8;border:5px double #ad4d3a;border-radius:50%;font-weight:900} +.report-seal{width:66px;height:66px;flex:none;margin:7px 0 4px;font-size:32px} +.report-title{margin-top:3px;font-size:21px;font-weight:900;line-height:1.2;text-align:center} +.report-person-label{color:#d7bd7d;font-size:14px;font-weight:850}.report-person{margin-left:8px;font-size:18px;font-weight:900} +.report-local-note{display:flex;width:100%;margin-top:auto;padding:6px 8px;color:#eadbb5;background:rgba(19,30,32,.38);border:1px solid rgba(223,196,134,.42);border-radius:6px;flex-direction:column;font-size:14px;font-weight:700;line-height:1.3} +.report-main{position:relative;display:grid;min-height:0;padding:46px 17px 12px;gap:8px;grid-template-rows:minmax(0,1fr) auto} +.report-capsule-safe-space{position:absolute;top:6px;right:8px;width:104px;height:36px;pointer-events:none} +.report-scroll{width:100%;height:100%;min-height:0} +.report-heading{display:block;padding-right:108px;color:#493325;font-size:24px;font-weight:900;line-height:1.22} +.report-disclaimer{display:block;margin-top:6px;padding:6px 9px;color:#644a37;background:#eadfbe;border-left:5px solid #8f3028;font-size:15px;font-weight:750;line-height:1.3} +.report-observation-list{display:grid;gap:6px;margin-top:7px} +.report-observation-card{display:grid;align-items:center;gap:9px;padding:6px 9px;background:rgba(255,250,232,.72);border:1px solid rgba(126,86,50,.32);border-radius:7px;grid-template-columns:34px minmax(0,1fr)} +.report-observation-card.is-family{border-left:5px solid #8c6f42}.report-observation-card.is-today{border-left:5px solid #47705c} +.report-observation-index{padding:5px;color:#fff0ce;background:#8f3028;border-radius:50%;font-size:17px;font-weight:900;text-align:center} +.report-observation-copy{display:grid;min-width:0;gap:2px} +.report-observation-label{color:#8d2f27;font-size:15px;font-weight:900} +.report-observation-value{color:#493325;font-size:19px;font-weight:750;line-height:1.32} +.report-actions{display:grid;gap:9px;grid-template-columns:repeat(2,minmax(0,1fr))} +.report-action{display:flex;min-height:52px;align-items:center;justify-content:center;padding:6px 10px;border:3px solid;border-radius:8px;font-size:19px;font-weight:900;line-height:1.2} +.report-action-primary{color:#fff0cb;background:#963128;border-color:#74231d}.report-action-secondary{color:#473326;background:#ead9b2;border-color:#997b54} +.report-invalid{display:flex;width:min(620px,100%);align-items:center;margin:auto;padding:20px;flex-direction:column}.report-invalid-seal{padding:12px;font-size:28px}.report-invalid-title{font-size:22px;font-weight:900} +@media(max-width:700px),(max-height:420px){ +.report-book{grid-template-columns:minmax(180px,2fr) minmax(0,5fr)}.report-identity{padding:7px 10px}.report-kicker{font-size:14px}.report-seal{width:42px;height:42px;margin:2px 0;border-width:3px;font-size:22px}.report-title{font-size:17px}.report-local-note{padding:3px 5px;font-size:12px;line-height:1.15} +.report-main{padding:41px 9px 6px;gap:4px}.report-heading{padding-right:100px;font-size:20px}.report-disclaimer{margin-top:3px;padding:3px 6px;font-size:13px;line-height:1.18}.report-observation-list{gap:3px;margin-top:3px}.report-observation-card{gap:6px;padding:3px 6px;grid-template-columns:28px minmax(0,1fr)}.report-observation-index{font-size:14px}.report-observation-label{font-size:13px}.report-observation-value{font-size:16px;line-height:1.18}.report-actions{gap:5px}.report-action{min-height:48px;padding:4px 6px;font-size:16px}} diff --git a/TongjiUniApp/native/tang-detective/pages/share/share.js b/TongjiUniApp/native/tang-detective/pages/share/share.js new file mode 100644 index 0000000..7b42694 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/share/share.js @@ -0,0 +1,112 @@ +const memoryCards = require('../../data/memoryCards') +const { chapterRoute } = require('../../utils/chapterRoute') + +const MEMORY_CARD_ID_PATTERN = /^S\d{2}-C\d{2}-MC\d{2}$/ +const INVALID_CARD_MESSAGE = '这张桂香记忆卡暂时没有找到。回到封面,还可以慢慢翻一回。' +const SHARE_PREVIEW_IMAGE = '/assets/share/guixiang-story-share-preview-v1.jpg' + +function memoryCardSharePath(memoryCard) { + const cardId = String(memoryCard && memoryCard.cardId || '').trim() + if (!cardId) return '/pages/home/home' + return `/pages/share/share?cardId=${encodeURIComponent(cardId)}&source=family-memory` +} + +function normalizeCardId(value) { + const raw = String(value || '').trim() + if (!raw || raw.length > 64) return '' + let decoded = raw + try { + decoded = decodeURIComponent(raw) + } catch (error) { + return '' + } + return MEMORY_CARD_ID_PATTERN.test(decoded) ? decoded : '' +} + +function findMemoryCard(value) { + const cardId = normalizeCardId(value) + if (!cardId) return null + return memoryCards.find((card) => card.cardId === cardId) || null +} + +function twoDigits(value) { + return String(value).padStart(2, '0') +} + +Page({ + data: { + memoryCard: null, + chapterLabel: '', + characterInitial: '', + invalidCard: false, + invalidCardMessage: INVALID_CARD_MESSAGE, + }, + + onLoad(options = {}) { + const memoryCard = findMemoryCard(options.cardId) + if (!memoryCard) { + this.setData({ + memoryCard: null, + chapterLabel: '', + characterInitial: '', + invalidCard: true, + }) + return + } + this.setData({ + memoryCard: { ...memoryCard }, + chapterLabel: `第${twoDigits(memoryCard.chapterNumber)}回`, + characterInitial: String(memoryCard.characterName || '桂').slice(0, 1), + invalidCard: false, + }) + }, + + openChapter() { + const memoryCard = this.data.memoryCard + if (!memoryCard || !memoryCard.chapterNumber) { + this.goHome() + return + } + wx.navigateTo({ + url: chapterRoute(memoryCard.chapterNumber), + }) + }, + + goHome() { + const target = { url: '/pages/home/home' } + if (typeof wx.reLaunch === 'function') { + wx.reLaunch(target) + return + } + wx.redirectTo(target) + }, + + onShareAppMessage() { + const memoryCard = this.data.memoryCard + if (!memoryCard) { + return { + title: '唐侦探:饭桌边,也有值得带回家说的话', + path: '/pages/home/home', + imageUrl: SHARE_PREVIEW_IMAGE, + } + } + return { + title: `带回家的一句话:${memoryCard.familyLine}`, + path: memoryCardSharePath(memoryCard), + imageUrl: SHARE_PREVIEW_IMAGE, + } + }, + + onShareTimeline() { + const memoryCard = this.data.memoryCard + return memoryCard + ? { + title: `桂香记忆:${memoryCard.tableEcho}`, + query: `cardId=${encodeURIComponent(memoryCard.cardId)}&source=family-memory`, + } + : { + title: '唐侦探:饭桌边,也有值得带回家说的话', + query: '', + } + }, +}) diff --git a/TongjiUniApp/native/tang-detective/pages/share/share.json b/TongjiUniApp/native/tang-detective/pages/share/share.json new file mode 100644 index 0000000..b8be3f9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/share/share.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "disableScroll": true +} diff --git a/TongjiUniApp/native/tang-detective/pages/share/share.wxml b/TongjiUniApp/native/tang-detective/pages/share/share.wxml new file mode 100644 index 0000000..415710a --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/share/share.wxml @@ -0,0 +1,71 @@ + diff --git a/TongjiUniApp/native/tang-detective/pages/share/share.wxss b/TongjiUniApp/native/tang-detective/pages/share/share.wxss new file mode 100644 index 0000000..bcff450 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/pages/share/share.wxss @@ -0,0 +1,326 @@ +.share-shell { + display: flex; + height: 100vh; + min-height: 0; + overflow: hidden; +} + +.share-card { + position: relative; + display: grid; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(205px, 2fr) minmax(0, 5fr); + border: 2px solid #9e845e; + border-radius: 9px; +} + +.share-identity { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + padding: 18px 22px; + overflow: hidden; + color: #f5e5bd; + background: + repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025) 0, rgba(255, 255, 255, 0.025) 1px, transparent 1px, transparent 6px), + #263a3e; + border-right: 5px solid #9b332a; + flex-direction: column; +} + +.share-kicker, +.share-meta-label, +.share-section-label, +.share-actions, +.share-invalid-copy { + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; +} + +.share-kicker { + align-self: flex-start; + color: #dfc486; + font-size: 16px; + font-weight: 850; + letter-spacing: 2px; +} + +.share-person-seal, +.share-invalid-seal { + display: flex; + align-items: center; + justify-content: center; + color: #8d2d25; + background: #efe0b8; + border: 5px double #ad4d3a; + border-radius: 50%; + font-weight: 900; +} + +.share-person-seal { + width: 78px; + height: 78px; + flex: none; + margin: 9px 0 6px; + font-size: 38px; +} + +.share-meta-block { + display: grid; + width: 100%; + min-width: 0; + align-items: start; + gap: 7px; + margin-top: 5px; + grid-template-columns: 42px minmax(0, 1fr); +} + +.share-meta-label { + color: #d5b974; + font-size: 15px; + font-weight: 850; +} + +.share-person { + font-size: 22px; + font-weight: 900; + line-height: 1.25; +} + +.share-era { + color: #f0deae; + font-size: 17px; + font-weight: 750; + line-height: 1.35; +} + +.share-chapter { + display: -webkit-box; + width: 100%; + margin-top: auto; + overflow: hidden; + color: #f7e9c7; + font-size: 17px; + font-weight: 850; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.share-main { + position: relative; + display: grid; + min-width: 0; + min-height: 0; + padding: 48px 20px 16px; + grid-template-rows: minmax(0, 1fr) auto; + gap: 12px; +} + +.share-capsule-safe-space { + position: absolute; + top: 6px; + right: 8px; + width: 104px; + height: 36px; + pointer-events: none; +} + +.share-copy { + width: 100%; + height: 100%; + min-height: 0; +} + +.share-section { + display: grid; + align-items: start; + gap: 12px; + padding: 11px 14px; + grid-template-columns: 92px minmax(0, 1fr); + border-top: 1px solid rgba(126, 86, 50, 0.24); +} + +.share-section:first-child { + padding-top: 0; + border-top: 0; +} + +.share-section-label { + color: #8d2f27; + font-size: 17px; + font-weight: 900; +} + +.share-quote, +.share-section-copy { + display: block; + font-size: 19px; + font-weight: 750; + line-height: 1.5; +} + +.share-quote { + color: #493325; + font-size: 21px; + font-weight: 850; +} + +.share-family { + background: rgba(167, 114, 54, 0.08); + border-bottom: 1px solid rgba(126, 86, 50, 0.22); +} + +.share-actions { + display: grid; + width: 100%; + min-width: 0; + gap: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.share-action { + display: flex; + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 54px; + align-items: center; + justify-content: center; + padding: 8px 14px; + border: 3px solid; + border-radius: 8px; + font-size: 20px; + font-weight: 900; + line-height: 1.2; + justify-self: stretch; +} + +.share-action-primary { + color: #fff0cb; + background: #963128; + border-color: #74231d; + box-shadow: 0 8px 18px rgba(111, 32, 27, 0.2); +} + +.share-action-secondary { + color: #473326; + background: #ead9b2; + border-color: #997b54; +} + +.share-action-forward { + color: #315545; + background: #dce7d9; + border-color: #64826e; +} + +.share-invalid { + display: flex; + width: min(620px, 100%); + min-height: 250px; + align-self: center; + align-items: center; + justify-content: center; + margin: auto; + padding: 24px 34px; + border-radius: 9px; + flex-direction: column; + text-align: center; +} + +.share-invalid-seal { + width: 68px; + height: 68px; + flex: none; + font-size: 31px; +} + +.share-invalid-title { + margin-top: 9px; + color: #783028; + font-size: 26px; + font-weight: 900; +} + +.share-invalid-copy { + margin-top: 7px; + color: #604b36; + font-size: 18px; + font-weight: 700; + line-height: 1.5; +} + +.share-invalid-action { + width: 210px; + margin-top: 15px; +} + +@media (max-width: 730px), (max-height: 400px) { + .share-card { + grid-template-columns: minmax(190px, 2fr) minmax(0, 5fr); + } + + .share-identity { + padding: 10px 15px; + } + + .share-kicker { + font-size: 14px; + } + + .share-person-seal { + width: 58px; + height: 58px; + margin: 5px 0 2px; + font-size: 29px; + } + + .share-person { + font-size: 19px; + } + + .share-era, + .share-chapter { + font-size: 15px; + } + + .share-main { + padding: 42px 12px 10px; + gap: 8px; + } + + .share-section { + gap: 8px; + padding: 7px 8px; + grid-template-columns: 78px minmax(0, 1fr); + } + + .share-section-label { + font-size: 15px; + } + + .share-quote, + .share-section-copy { + font-size: 16px; + line-height: 1.38; + } + + .share-actions { + gap: 8px; + } + + .share-action { + min-height: 48px; + padding: 6px 10px; + font-size: 18px; + } + + .share-invalid { + min-height: 220px; + padding: 17px 24px; + } +} diff --git a/TongjiUniApp/native/tang-detective/utils/chapterProgress.js b/TongjiUniApp/native/tang-detective/utils/chapterProgress.js new file mode 100644 index 0000000..dcb4af4 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/utils/chapterProgress.js @@ -0,0 +1,148 @@ +function eventIdSet(chapter) { + return new Set( + Array.isArray(chapter && chapter.events) + ? chapter.events.map((event) => event.hotspotId) + : [], + ) +} + +function normalizeCompletedIds(chapter, completedIds) { + const knownIds = eventIdSet(chapter) + const seen = new Set() + return (Array.isArray(completedIds) ? completedIds : []).filter((id) => { + if (!knownIds.has(id) || seen.has(id)) return false + seen.add(id) + return true + }) +} + +function getPeopleProgress(chapter, completedIds) { + const knownIds = eventIdSet(chapter) + const completed = new Set(normalizeCompletedIds(chapter, completedIds)) + const people = Array.isArray(chapter && chapter.people) ? chapter.people : [] + + return people + .map((person) => { + const personEventIds = (Array.isArray(person.eventIds) + ? person.eventIds + : [] + ).filter((id) => knownIds.has(id)) + const completedForPerson = personEventIds.filter((id) => completed.has(id)) + const remaining = personEventIds.length - completedForPerson.length + if (!personEventIds.length) return null + + let markerStatus = '看看' + let statusAria = `${person.name}有1处故事细节,可以看看发生了什么` + if (remaining === 0) { + markerStatus = '看过' + statusAria = `${person.name}这一处已经看过` + } else if (completedForPerson.length > 0) { + markerStatus = '再看看' + statusAria = `${person.name}还有${remaining}处细节,可以再看看` + } else if (remaining > 1) { + markerStatus = `${remaining}处` + statusAria = `${person.name}有${remaining}处故事细节` + } + + return { + ...person, + eventIds: personEventIds, + completed: remaining === 0, + completedEvents: completedForPerson.length, + remaining, + markerStatus, + statusAria, + } + }) + .filter(Boolean) +} + +function selectNextPerson(people, activeInstanceId) { + const active = people.find( + (person) => person.instanceId === activeInstanceId && person.remaining > 0, + ) + if (active) return active + + const partiallyCompleted = people.find( + (person) => person.completedEvents > 0 && person.remaining > 0, + ) + return partiallyCompleted || people.find((person) => person.remaining > 0) || null +} + +function getChapterProgress( + chapter, + completedIds, + activeInstanceId = '', + chapterFinished = false, +) { + const normalizedIds = normalizeCompletedIds(chapter, completedIds) + const totalEvents = Array.isArray(chapter && chapter.events) + ? chapter.events.length + : 0 + const completedCount = normalizedIds.length + const remainingCount = Math.max(0, totalEvents - completedCount) + const complete = totalEvents > 0 && remainingCount === 0 + const people = getPeopleProgress(chapter, normalizedIds) + const nextPerson = complete + ? null + : selectNextPerson(people, activeInstanceId) + const repeatPerson = Boolean( + nextPerson + && ( + nextPerson.completedEvents > 0 + || ( + activeInstanceId + && nextPerson.instanceId === activeInstanceId + ) + ), + ) + + let nextStepLabel = '下一步' + let nextStepText = '看看画中的人物和手边物件,跟着这一回往下走。' + let continueLabel = '完成后继续' + if (complete) { + nextStepLabel = chapterFinished ? '本回已收好' : '4处线索已经看过' + nextStepText = chapterFinished + ? '这一回已经收进画册,也可以再次打开情感互动。' + : '情感互动已经开启,点右侧按钮打开这一页。' + continueLabel = '4处线索看完,打开情感互动' + } else if (nextPerson && repeatPerson) { + nextStepText = `还差${remainingCount}处。再看看${nextPerson.name}的手边。` + continueLabel = `下一步:再看看${nextPerson.name}` + } else if (nextPerson) { + const multiEventNote = nextPerson.remaining > 1 + ? ` ${nextPerson.name}身边还有${nextPerson.remaining}处细节。` + : '' + nextStepText = `还差${remainingCount}处。接着看看${nextPerson.name}。${multiEventNote}` + continueLabel = `下一步:看看${nextPerson.name}` + } + + const emotionStatusText = complete + ? ( + chapterFinished + ? '本回已收进画册,可再次打开' + : `${totalEvents}处线索已经看过,可以打开` + ) + : `还差${remainingCount}处线索,看完后开启` + + return { + completedIds: normalizedIds, + completedCount, + totalEvents, + remainingCount, + complete, + people, + nextPerson, + nextAction: complete ? 'complete' : (repeatPerson ? 'repeat' : 'next'), + nextStepLabel, + nextStepText, + continueLabel, + emotionStatusText, + } +} + +module.exports = { + normalizeCompletedIds, + getPeopleProgress, + getChapterProgress, +} diff --git a/TongjiUniApp/native/tang-detective/utils/chapterRoute.js b/TongjiUniApp/native/tang-detective/utils/chapterRoute.js new file mode 100644 index 0000000..e5f13b3 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/utils/chapterRoute.js @@ -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, +} diff --git a/TongjiUniApp/native/tang-detective/utils/comicLayout.js b/TongjiUniApp/native/tang-detective/utils/comicLayout.js new file mode 100644 index 0000000..59a29e9 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/utils/comicLayout.js @@ -0,0 +1,83 @@ +const DEFAULT_COMIC_ART_ASPECT_RATIO = 16 / 9 +const MIN_COMIC_ART_ASPECT_RATIO = 1.4 +const MAX_COMIC_ART_ASPECT_RATIO = 3 + +function normalizeComicArtAspectRatio(page) { + const requested = Number(page && page.artAspectRatio) + if ( + Number.isFinite(requested) + && requested >= MIN_COMIC_ART_ASPECT_RATIO + && requested <= MAX_COMIC_ART_ASPECT_RATIO + ) { + return requested + } + return DEFAULT_COMIC_ART_ASPECT_RATIO +} + +function getComicArtStageStyle(metrics, page) { + const safeMetrics = metrics || {} + const contentWidth = Math.max( + 1, + Number(safeMetrics.windowWidth || 1) + - Number(safeMetrics.safeLeft || 0) + - Number(safeMetrics.safeRight || 0), + ) + const contentHeight = Math.max( + 1, + Number(safeMetrics.windowHeight || 1) + - Number(safeMetrics.topbarHeight || 0) + - Number(safeMetrics.safeBottom || 0), + ) + const aspectRatio = normalizeComicArtAspectRatio(page) + + if (safeMetrics.compactHeight) { + // A phone in landscape does not have enough height for a 16:9 picture + // plus an elder-readable caption below it. Compact pages therefore open + // like a two-page lianhuanhua spread: illustration on the left, caption + // paper on the right. Keep these numbers in lockstep with chapter.wxss. + const captionWidth = Math.max(190, contentWidth * 0.28) + const artColumnWidth = Math.max(1, contentWidth - captionWidth) + const artColumnPadding = 12 + const availableWidth = Math.max(1, artColumnWidth - artColumnPadding) + const availableHeight = Math.max(1, contentHeight - artColumnPadding) + const artWidth = Math.max( + 1, + Math.min(availableWidth, availableHeight * aspectRatio), + ) + const artHeight = artWidth / aspectRatio + return [ + `width:${Math.round(artWidth)}px`, + `height:${Math.round(artHeight)}px`, + ].join(';') + } + + // Keep this in lockstep with chapter.wxss: + // regular: minmax(0, 3fr) minmax(190px, 1fr) + // When the caption track reaches its minimum, the art receives the + // remainder. Calculating that exact height prevents max-height from + // compressing only the stage height and stretching a 16:9 illustration. + const proportionalArtHeight = contentHeight * 0.75 + const captionMinimum = 190 + const artRowHeight = Math.max( + 1, + Math.min( + proportionalArtHeight, + contentHeight - captionMinimum, + ), + ) + const artWidth = Math.max( + 1, + Math.min(contentWidth, artRowHeight * aspectRatio), + ) + const artHeight = artWidth / aspectRatio + return [ + `width:${Math.round(artWidth)}px`, + `height:${Math.round(artHeight)}px`, + ].join(';') +} + +module.exports = { + DEFAULT_COMIC_ART_ASPECT_RATIO, + normalizeComicArtAspectRatio, + getComicArtStageStyle, +} diff --git a/TongjiUniApp/native/tang-detective/utils/layout.js b/TongjiUniApp/native/tang-detective/utils/layout.js new file mode 100644 index 0000000..1aea4f7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/utils/layout.js @@ -0,0 +1,179 @@ +function finiteNumber(value, fallback = 0) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)) +} + +const SCENE_WIDTH = 1400 +const SCENE_HEIGHT = 788 +const SCENE_ASPECT_RATIO = SCENE_WIDTH / SCENE_HEIGHT + +function getChapterLayoutMetrics(windowInfo = {}, menuRect = {}) { + const windowWidth = Math.max(320, finiteNumber(windowInfo.windowWidth, 844)) + const windowHeight = Math.max(240, finiteNumber(windowInfo.windowHeight, 390)) + const safeArea = windowInfo.safeArea || {} + const safeLeft = clamp(finiteNumber(safeArea.left, 0), 0, windowWidth / 3) + const safeRightEdge = clamp( + finiteNumber(safeArea.right, windowWidth), + windowWidth * 2 / 3, + windowWidth, + ) + const safeRight = clamp(windowWidth - safeRightEdge, 0, windowWidth / 3) + const statusBarHeight = Math.max( + 0, + finiteNumber(windowInfo.statusBarHeight, 0), + ) + const safeTop = Math.max( + 0, + statusBarHeight, + finiteNumber(safeArea.top, 0), + ) + const safeBottomEdge = clamp( + finiteNumber(safeArea.bottom, windowHeight), + windowHeight * 2 / 3, + windowHeight, + ) + const safeBottom = clamp( + windowHeight - safeBottomEdge, + 0, + windowHeight / 3, + ) + const menuLeft = finiteNumber(menuRect.left, windowWidth) + const hasExplicitMenuTop = Number.isFinite(Number(menuRect.top)) + && Number(menuRect.top) >= 0 + const menuTop = hasExplicitMenuTop ? Number(menuRect.top) : 0 + const menuBottom = finiteNumber(menuRect.bottom, 0) + const explicitMenuHeight = Number(menuRect.height) + const menuHeight = Number.isFinite(explicitMenuHeight) + && explicitMenuHeight > 0 + ? explicitMenuHeight + : ( + hasExplicitMenuTop && menuBottom > menuTop + ? menuBottom - menuTop + : 32 + ) + const hasMenuRect = menuLeft > 0 + && menuLeft < windowWidth + && menuBottom > 0 + + // 横屏 rpx 按屏幕宽度换算;576px 高的设备仍属于短高屏。 + // 这里使用实际 windowHeight,而不是设备型号或像素比。 + const compactHeight = windowHeight <= 620 + // Every visible top-bar action has a 48px minimum target. Keep the + // calculated row at least as tall so compact landscape phones do not clip + // the button above or below the explicit top-bar height. + const rowHeight = Math.max(48, menuHeight) + const inferredMenuTop = hasMenuRect + ? ( + hasExplicitMenuTop + ? menuTop + : Math.max(safeTop, menuBottom - menuHeight) + ) + : safeTop + const topPadding = Math.max( + safeTop, + hasMenuRect + ? inferredMenuTop - Math.max(0, (rowHeight - menuHeight) / 2) + : safeTop + (compactHeight ? 3 : 5), + ) + const bottomPadding = compactHeight ? 4 : 6 + const borderHeight = 3 + const topbarHeight = Math.ceil( + topPadding + rowHeight + bottomPadding + borderHeight, + ) + const leftInset = safeLeft + (compactHeight ? 10 : 14) + const capsuleReserve = hasMenuRect + ? Math.max( + safeRight + (compactHeight ? 10 : 14), + windowWidth - menuLeft + (compactHeight ? 8 : 10), + ) + : safeRight + (compactHeight ? 12 : 16) + const contentHeight = Math.max( + 1, + windowHeight - topbarHeight - safeBottom, + ) + const layoutWidth = Math.max( + 1, + windowWidth - safeLeft - safeRight, + ) + const sceneColumnWidth = layoutWidth * 0.6 + const stageWidth = Math.max( + 1, + Math.min(sceneColumnWidth, contentHeight * SCENE_ASPECT_RATIO), + ) + const stageHeight = stageWidth / SCENE_ASPECT_RATIO + const markerWidth = compactHeight + ? clamp(stageWidth * 0.24, 142, 170) + : clamp(windowWidth / 750 * 200, 160, 220) + const markerHeight = compactHeight + ? 56 + : clamp(windowWidth / 750 * 86, 64, 94) + + return { + windowWidth, + windowHeight, + compactHeight, + safeLeft, + safeRight, + safeTop, + safeBottom, + topPadding, + bottomPadding, + rowHeight, + topbarHeight, + leftInset, + capsuleReserve, + modalTop: Math.max( + safeTop + (compactHeight ? 4 : 8), + hasMenuRect ? menuBottom + 4 : 0, + ), + stageWidth: Math.round(stageWidth), + stageHeight: Math.round(stageHeight), + markerWidth: Math.round(markerWidth), + markerHeight: Math.round(markerHeight), + sceneWidth: SCENE_WIDTH, + sceneHeight: SCENE_HEIGHT, + sceneAspectRatio: SCENE_ASPECT_RATIO, + } +} + +function getMarkerPosition(position = {}, metrics = {}) { + const widthPercent = finiteNumber(position.widthPercent, 0) + const heightPercent = finiteNumber(position.heightPercent, 0) + const rawX = finiteNumber(position.xPercent, 50) + widthPercent / 2 + const rawY = finiteNumber(position.yPercent, 50) + heightPercent / 2 + const stageWidth = Math.max(1, finiteNumber(metrics.stageWidth, 500)) + const stageHeight = Math.max(1, finiteNumber(metrics.stageHeight, 280)) + const markerWidth = finiteNumber(metrics.markerWidth, 168) + const markerHeight = finiteNumber(metrics.markerHeight, 64) + const horizontalMargin = clamp( + markerWidth / 2 / stageWidth * 100 + 1.5, + 2, + 48, + ) + const verticalMargin = clamp( + markerHeight / 2 / stageHeight * 100 + 2, + 2, + 48, + ) + const clampedX = clamp(rawX, horizontalMargin, 100 - horizontalMargin) + const clampedY = clamp(rawY, verticalMargin, 100 - verticalMargin) + + return { + xPercent: clampedX, + yPercent: clampedY, + xPx: Math.round(clampedX / 100 * stageWidth), + yPx: Math.round(clampedY / 100 * stageHeight), + } +} + +module.exports = { + SCENE_WIDTH, + SCENE_HEIGHT, + SCENE_ASPECT_RATIO, + getChapterLayoutMetrics, + getMarkerPosition, +} diff --git a/TongjiUniApp/native/tang-detective/utils/memoryCollection.js b/TongjiUniApp/native/tang-detective/utils/memoryCollection.js new file mode 100644 index 0000000..7780381 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/utils/memoryCollection.js @@ -0,0 +1,86 @@ +const SNAPSHOT_FIELDS = [ + 'cardId', + 'chapterId', + 'chapterNumber', + 'chapterTitle', + 'characterName', + 'eraLine', + 'tableEcho', + 'lifeAction', + 'familyLine', + 'eraObject', +] + +function cleanSnapshot(source = {}) { + return SNAPSHOT_FIELDS.reduce((snapshot, field) => { + const value = source[field] + if (field === 'chapterNumber') { + const number = Number(value) + if (number > 0) snapshot[field] = number + } else if (typeof value === 'string' && value.trim()) { + snapshot[field] = value.trim() + } + return snapshot + }, {}) +} + +function normalizeProgress(progress) { + const next = progress && typeof progress === 'object' + ? { ...progress } + : {} + next.collectedMemoryCards = Array.isArray(next.collectedMemoryCards) + ? [...new Set(next.collectedMemoryCards.filter((id) => typeof id === 'string' && id))] + : [] + next.memoryCardSnapshots = next.memoryCardSnapshots + && typeof next.memoryCardSnapshots === 'object' + && !Array.isArray(next.memoryCardSnapshots) + ? { ...next.memoryCardSnapshots } + : {} + return next +} + +function addMemoryCard(progress, card, chapter = {}) { + const next = normalizeProgress(progress) + if (!card || !card.cardId) return next + const snapshot = cleanSnapshot({ + ...chapter, + ...card, + chapterId: chapter.chapterId || card.chapterId, + chapterNumber: chapter.chapterNumber || card.chapterNumber, + chapterTitle: chapter.chapterTitle || chapter.title || card.chapterTitle, + }) + if (!snapshot.cardId) return next + if (!next.collectedMemoryCards.includes(snapshot.cardId)) { + next.collectedMemoryCards.push(snapshot.cardId) + } + next.memoryCardSnapshots[snapshot.cardId] = snapshot + return next +} + +function getCollectedMemories(progress, catalog = []) { + const normalized = normalizeProgress(progress) + const catalogById = (Array.isArray(catalog) ? catalog : []).reduce( + (map, card) => { + if (card && card.cardId) map[card.cardId] = cleanSnapshot(card) + return map + }, + {}, + ) + + return normalized.collectedMemoryCards + .map((cardId) => { + const fallback = catalogById[cardId] || {} + const saved = cleanSnapshot(normalized.memoryCardSnapshots[cardId] || {}) + const card = cleanSnapshot({ ...fallback, ...saved, cardId }) + return card.chapterNumber ? card : null + }) + .filter(Boolean) + .sort((a, b) => a.chapterNumber - b.chapterNumber) +} + +module.exports = { + addMemoryCard, + cleanSnapshot, + getCollectedMemories, + normalizeProgress, +} diff --git a/TongjiUniApp/native/tang-detective/utils/storage.js b/TongjiUniApp/native/tang-detective/utils/storage.js new file mode 100644 index 0000000..e8969da --- /dev/null +++ b/TongjiUniApp/native/tang-detective/utils/storage.js @@ -0,0 +1,142 @@ +const PROGRESS_KEY = 'tang-detective-progress-v1' +const SETTINGS_KEY = 'tang-detective-settings-v1' +const AUDIO_KEY = 'tang-detective-audio-progress-v1' + +function defaultProgress() { + return { + completedHotspots: {}, + completedChapters: [], + lastChapter: 1, + } +} + +function isPlainRecord(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function chapterId(number) { + return `S01-C${String(number).padStart(2, '0')}` +} + +function eventId(number) { + return `S01-H${String(number).padStart(2, '0')}` +} + +function normalizeCompletedHotspots(value) { + const source = isPlainRecord(value) ? value : {} + const normalized = {} + for (let chapterNumber = 1; chapterNumber <= 15; chapterNumber += 1) { + const id = chapterId(chapterNumber) + const stored = Array.isArray(source[id]) ? source[id] : [] + const firstEvent = (chapterNumber - 1) * 4 + 1 + const allowed = new Set( + Array.from({ length: 4 }, (_, index) => eventId(firstEvent + index)), + ) + const clean = [...new Set(stored.filter((item) => allowed.has(item)))] + if (clean.length > 0 || Object.prototype.hasOwnProperty.call(source, id)) { + normalized[id] = clean + } + } + return normalized +} + +function normalizeCompletedChapters(value) { + if (!Array.isArray(value)) return [] + const allowed = new Set( + Array.from({ length: 15 }, (_, index) => chapterId(index + 1)), + ) + return [...new Set(value.filter((item) => allowed.has(item)))] +} + +function normalizeProgress(value) { + const source = isPlainRecord(value) ? value : {} + const lastChapter = Number(source.lastChapter) + return { + ...source, + completedHotspots: normalizeCompletedHotspots(source.completedHotspots), + completedChapters: normalizeCompletedChapters(source.completedChapters), + lastChapter: Number.isInteger(lastChapter) && lastChapter >= 1 && lastChapter <= 15 + ? lastChapter + : 1, + } +} + +function normalizeSettings(value) { + const source = isPlainRecord(value) ? value : {} + return { + ...source, + fontScale: source.fontScale === 'xlarge' ? 'xlarge' : 'large', + sound: source.sound !== false, + } +} + +function read(key, fallback) { + try { + const value = wx.getStorageSync(key) + return value || fallback + } catch (error) { + return fallback + } +} + +function write(key, value) { + try { + wx.setStorageSync(key, value) + return true + } catch (error) { + // Storage failure must never block the text game. + return false + } +} + +function getProgress() { + return normalizeProgress(read(PROGRESS_KEY, null)) +} + +function saveProgress(progress) { + return write(PROGRESS_KEY, normalizeProgress(progress)) +} + +/** + * Start the story again without deleting the reader's collected memory cards + * or accessibility preferences. Only story/level progress is reset. + */ +function resetStoryProgress() { + const current = getProgress() + const next = { + ...current, + ...defaultProgress(), + } + delete next.comicReaderByChapter + delete next.lastPageId + if (!saveProgress(next)) return false + write(AUDIO_KEY, {}) + return true +} + +function getSettings() { + return normalizeSettings(read(SETTINGS_KEY, null)) +} + +function saveSettings(settings) { + return write(SETTINGS_KEY, normalizeSettings(settings)) +} + +function getAudioProgress() { + const value = read(AUDIO_KEY, {}) + return isPlainRecord(value) ? value : {} +} + +function saveAudioProgress(progress) { + write(AUDIO_KEY, progress) +} + +module.exports = { + getProgress, + saveProgress, + resetStoryProgress, + getSettings, + saveSettings, + getAudioProgress, + saveAudioProgress, +} diff --git a/TongjiUniApp/native/tang-detective/utils/updateManager.js b/TongjiUniApp/native/tang-detective/utils/updateManager.js new file mode 100644 index 0000000..062bfd7 --- /dev/null +++ b/TongjiUniApp/native/tang-detective/utils/updateManager.js @@ -0,0 +1,48 @@ +function showUpdateReady(updatePlatform, manager) { + if (!updatePlatform || typeof updatePlatform.showModal !== 'function') return + updatePlatform.showModal({ + title: '新版本已经准备好', + content: '重新打开后即可使用新版本。现在更新吗?', + confirmText: '现在更新', + cancelText: '稍后再说', + success(result) { + if (result && result.confirm && typeof manager.applyUpdate === 'function') { + manager.applyUpdate() + } + }, + }) +} + +function showUpdateFailed(updatePlatform) { + if (!updatePlatform || typeof updatePlatform.showModal !== 'function') return + updatePlatform.showModal({ + title: '新版本暂时没有下载完成', + content: '当前内容仍可继续使用。请检查网络后,完全退出微信再重新打开。', + showCancel: false, + confirmText: '知道了', + }) +} + +function setupUpdateManager(updatePlatform) { + if (!updatePlatform || typeof updatePlatform.getUpdateManager !== 'function') return false + try { + const manager = updatePlatform.getUpdateManager() + if (!manager) return false + + if (typeof manager.onUpdateReady === 'function') { + manager.onUpdateReady(() => showUpdateReady(updatePlatform, manager)) + } + if (typeof manager.onUpdateFailed === 'function') { + manager.onUpdateFailed(() => showUpdateFailed(updatePlatform)) + } + return true + } catch (error) { + return false + } +} + +module.exports = { + setupUpdateManager, + showUpdateFailed, + showUpdateReady, +} diff --git a/TongjiUniApp/scripts/check-tang-native-compiler.cjs b/TongjiUniApp/scripts/check-tang-native-compiler.cjs new file mode 100644 index 0000000..a60fb0c --- /dev/null +++ b/TongjiUniApp/scripts/check-tang-native-compiler.cjs @@ -0,0 +1,25 @@ +// Uses the installed WeChat compiler binaries only. No DevTools UI, server, +// account, network, upload, media playback or files outside the build are used. +const fs = require('node:fs') +const path = require('node:path') +const { spawnSync } = require('node:child_process') +const root = path.resolve(__dirname, '../dist/build/mp-weixin') +const bin = process.env.TANG_WECHAT_COMPILER_DIR || '/Applications/wechatwebdevtools.app/Contents/Resources/app.asar.unpacked/node_modules/wcc-exec' +const app = JSON.parse(fs.readFileSync(path.join(root, 'app.json'), 'utf8')) +const pages = [...app.pages, ...(app.subPackages || []).flatMap(pkg => pkg.pages.map(page => `${pkg.root}/${page}`))] + .filter(page => page.startsWith('tang-detective/')) +if (pages.length !== 24) throw new Error(`Expected 24 native pages, found ${pages.length}`) +const results = [] +for (const [tool, args] of [ + ['wcc', pages.map(page => './' + page + '.wxml')], + ['wcsc', ['-pc', String(pages.length), ...pages.map(page => './' + page + '.wxss'), './tang-detective/shared.wxss']], +]) { + if (!fs.existsSync(path.join(bin, tool))) throw new Error(`${tool} not available; set TANG_WECHAT_COMPILER_DIR`) + const run = spawnSync(path.join(bin, tool), args, { cwd: root, encoding: 'utf8', timeout: 30000, maxBuffer: 64 * 1024 * 1024 }) + results.push({ tool, exitCode: run.status, passed: run.status === 0 && !run.error, + generatedOutputBytes: Buffer.byteLength(run.stdout || ''), + diagnostics: (run.stderr || '').slice(0, 3000), error: run.error ? run.error.message : null }) +} +console.log(JSON.stringify({ nativePages: pages.length, results, + boundary: 'Installed compiler syntax check only, not simulator/device, networking or upload acceptance.' }, null, 2)) +if (results.some(result => !result.passed)) process.exitCode = 1 diff --git a/TongjiUniApp/scripts/tang-cos/config.mjs b/TongjiUniApp/scripts/tang-cos/config.mjs new file mode 100644 index 0000000..df8057f --- /dev/null +++ b/TongjiUniApp/scripts/tang-cos/config.mjs @@ -0,0 +1,95 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' + +export function dependency(name) { + const root = process.env.TANG_COS_TOOLS_DIR + return createRequire(root ? path.join(path.resolve(root), 'package.json') : import.meta.url)(name) +} + +// Fail closed rather than evaluating PHP or guessing deployment settings. +export function configuredDatabase(serverDirectory, environment = process.env) { + const filename = path.join(serverDirectory, 'config/database.php') + const source = fs.readFileSync(filename, 'utf8') + if (fs.existsSync(path.join(serverDirectory, '.env'))) { + throw new Error('SERVER_ENV_REQUIRES_NATIVE_RUNTIME') + } + function value(key) { + const envKey = `DATABASE_${key.toUpperCase()}` + if (environment[envKey] !== undefined) return environment[envKey] + if (environment[`PHP_${envKey}`] !== undefined) return environment[`PHP_${envKey}`] + const match = source.match(new RegExp(`env\\('database\\.${key}',\\s*'((?:\\\\.|[^'\\\\])*)'\\)`)) + if (!match) throw new Error('UNSUPPORTED_DATABASE_CONFIG') + return match[1].replace(/\\([\\'])/g, '$1') + } + const prefix = value('prefix') + const port = Number(value('hostport')) + if (!/^[a-zA-Z0-9_]+$/.test(prefix) || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('INVALID_DATABASE_CONFIG') + } + const options = { + host: value('hostname'), port, database: value('database'), user: value('username'), password: value('password'), + connectTimeout: 8000, multipleStatements: false, + ssl: { rejectUnauthorized: true, verifyIdentity: true }, + } + // Supply only a trusted CA obtained from the server administrator; never fetch and trust a peer certificate. + if (environment.TANG_DB_CA_FILE) options.ssl.ca = fs.readFileSync(environment.TANG_DB_CA_FILE, 'utf8') + return { options, prefix } +} + +export function validateCosConfig(config, driver) { + if (driver !== 'qcloud') throw new Error('CONFIGURED_DRIVER_IS_NOT_COS') + if (!/^[a-z0-9][a-z0-9-]*-\d+$/.test(config.bucket || '') || !/^[a-z][a-z0-9-]+$/.test(config.region || '')) { + throw new Error('INVALID_COS_DESTINATION') + } + if (typeof config.access_key !== 'string' || !config.access_key || typeof config.secret_key !== 'string' || !config.secret_key) { + throw new Error('COS_CREDENTIALS_MISSING') + } + const rawDomain = String(config.domain || '').trim() + const base = new URL(rawDomain ? (/^https?:\/\//i.test(rawDomain) ? rawDomain : `https://${rawDomain}`) + : `https://${config.bucket}.cos.${config.region}.myqcloud.com`) + if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) { + throw new Error('COS_REQUIRES_UNSIGNED_HTTPS_BASE_URL') + } + return { ...config, baseUrl: base.href.replace(/\/+$/, '') } +} + +export async function withDeadline(operation, milliseconds, abort, code) { + let timer + try { + return await Promise.race([operation, new Promise((_, reject) => { + timer = setTimeout(() => { + try { abort() } catch {} + reject(new Error(code)) + }, milliseconds) + })]) + } finally { + clearTimeout(timer) + } +} + +export async function readCosConfig(serverDirectory) { + const mysql = dependency('mysql2') + const { options, prefix } = configuredDatabase(serverDirectory) + let connection + let destroyed = false + const abort = () => { destroyed = true; try { connection?.destroy() } catch {} } + try { + connection = mysql.createConnection(options).promise() + await withDeadline(connection.connect(), 8000, abort, 'DATABASE_CONNECT_TIMEOUT') + const [rows] = await withDeadline(connection.execute(`SELECT name,value FROM \`${prefix}config\` WHERE type=? AND name IN (?,?)`, + ['storage', 'default', 'qcloud']), 8000, abort, 'DATABASE_QUERY_TIMEOUT') + const config = JSON.parse(rows.find(row => row.name === 'qcloud')?.value || '{}') + return validateCosConfig(config, rows.find(row => row.name === 'default')?.value) + } finally { + if (connection && !destroyed) { + try { await withDeadline(connection.end(), 2000, abort, 'DATABASE_CLOSE_TIMEOUT') } catch { abort() } + } + } +} + +// Never print raw MySQL/COS errors: they may contain connection values or signed request URLs. +export function safeError(error) { + const code = String(error?.code || error?.message || '') + return /^[A-Z][A-Z0-9_]{2,80}$/.test(code) ? code : 'REDACTED_OPERATION_ERROR' +} diff --git a/TongjiUniApp/scripts/tang-cos/package-lock.json b/TongjiUniApp/scripts/tang-cos/package-lock.json new file mode 100644 index 0000000..98181f5 --- /dev/null +++ b/TongjiUniApp/scripts/tang-cos/package-lock.json @@ -0,0 +1,909 @@ +{ + "name": "tang-detective-cos-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tang-detective-cos-tools", + "dependencies": { + "cos-nodejs-sdk-v5": "3.0.0", + "mysql2": "3.24.4" + } + }, + "node_modules/@types/node": { + "version": "26.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.0.tgz", + "integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/cos-fast-xml-parser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cos-fast-xml-parser/-/cos-fast-xml-parser-1.0.0.tgz", + "integrity": "sha512-kOPJb1cuj+gc5E8jN5ekZn4rgQHaxYTLcQT+jmbHtlTNcNjv7wnOBEYH2uRA0pG3h8giot4z9h4WFLDuXATZwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + }, + "engines": { + "node": ">= 9" + } + }, + "node_modules/cos-nodejs-sdk-v5": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cos-nodejs-sdk-v5/-/cos-nodejs-sdk-v5-3.0.0.tgz", + "integrity": "sha512-xUqiDdUxfEjfaWoyBA3qsSnlQVHPz13DRFErL+NliadBVUeeZhAPHGVa2inGpUvPTOs52ALIKkMbzL+inSqvXg==", + "license": "ISC", + "dependencies": { + "cos-fast-xml-parser": "^1.0.0", + "cos-request": "^1.3.0", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 9" + } + }, + "node_modules/cos-request": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/cos-request/-/cos-request-1.3.3.tgz", + "integrity": "sha512-zD7fKMAIMfJNHssx8VZE5mUQ67hs3QDi7S2BX7JuD8MzT1XYqEOAN42PJ0BtRhgutnn+8HiUF+Fup+j5zORasQ==", + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.5.6", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "^6.15.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~4.1.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", + "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mysql2": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz", + "integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT", + "peer": true + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + } + } +} diff --git a/TongjiUniApp/scripts/tang-cos/package.json b/TongjiUniApp/scripts/tang-cos/package.json new file mode 100644 index 0000000..6ea5b65 --- /dev/null +++ b/TongjiUniApp/scripts/tang-cos/package.json @@ -0,0 +1,9 @@ +{ + "name": "tang-detective-cos-tools", + "private": true, + "type": "module", + "dependencies": { + "cos-nodejs-sdk-v5": "3.0.0", + "mysql2": "3.24.4" + } +} diff --git a/TongjiUniApp/scripts/tang-cos/prepare-admin-upload.mjs b/TongjiUniApp/scripts/tang-cos/prepare-admin-upload.mjs new file mode 100644 index 0000000..6249147 --- /dev/null +++ b/TongjiUniApp/scripts/tang-cos/prepare-admin-upload.mjs @@ -0,0 +1,33 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { inventory, hash } from './upload.mjs' + +const project = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const stage = process.argv[2] +if (!stage || !path.isAbsolute(stage) || fs.existsSync(stage)) throw new Error('NEW_ABSOLUTE_STAGING_DIRECTORY_REQUIRED') +const source = inventory(project) +const unique = [...new Map(source.entries.map(entry => [entry.objectKey, entry])).values()] +const shared = source.entries.find(entry => entry.sourcePath === 'assets/share/guixiang-story-share-preview-v1.jpg') +if (!shared) throw new Error('FIRST_VERIFIED_SAMPLE_NOT_FOUND') +const sampleUrl = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081718480ee488780.jpg' +const runId = crypto.randomUUID() +const counters = { image: 0, audio: 0 } +fs.mkdirSync(stage, { recursive: false }) +const entries = unique.map(entry => { + const sample = entry.sha256 === shared.sha256 + const stagedName = sample ? null : `tang-20260908-1714-${entry.kind}-${String(++counters[entry.kind]).padStart(3, '0')}${path.extname(entry.sourcePath)}` + const stagedPath = stagedName ? path.join(stage, stagedName) : null + if (stagedPath) { + const original = path.join(source.sourceDirectory, entry.sourcePath) + fs.copyFileSync(original, stagedPath, fs.constants.COPYFILE_EXCL) + if (hash(fs.readFileSync(stagedPath)) !== entry.sha256) throw new Error('STAGING_BYTES_DIFFER') + } + return { ...entry, stagedName, stagedPath, observedUrl: sample ? sampleUrl : null } +}) +const result = { schemaVersion: 1, uploadRunId: runId, sourceManifestSha256: source.sourceManifestSha256, + sourceDirectory: source.sourceDirectory, stagingDirectory: stage, createdAt: new Date().toISOString(), entries } +const output = path.join(project, 'build/tang-detective-admin-upload-plan.json') +fs.writeFileSync(output, JSON.stringify(result, null, 2) + '\n', { flag: 'wx' }) +console.log(JSON.stringify({ uploadRunId: runId, entries: entries.length, staged: counters, stage, output, originalsChanged: false }, null, 2)) diff --git a/TongjiUniApp/scripts/tang-cos/prune-verified-media.mjs b/TongjiUniApp/scripts/tang-cos/prune-verified-media.mjs new file mode 100644 index 0000000..bb82511 --- /dev/null +++ b/TongjiUniApp/scripts/tang-cos/prune-verified-media.mjs @@ -0,0 +1,108 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { PROJECT, inventory, hash } from './upload.mjs' +import { loadCosMediaManifest, validateCosMediaManifest } from '../../build/tang-detective-cos-media.mjs' +import { validatePruneReceipt, readSourceManifest } from '../../build/tang-detective-source-validation.mjs' + +// Deliberately task-scoped, recoverable cleanup. Never removes a cloud object or recurses over a deletion target. +const mode = process.argv[2] +if (!['check', 'apply'].includes(mode)) throw new Error('USE_CHECK_OR_APPLY') +const backupDirectory = '/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE' +const archive = path.join(backupDirectory, 'original-media.tar.gz') +const recoveryDirectory = path.join(backupDirectory, 'pruned-originals') +const expectedArchiveSha256 = '656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61' +const unused = ['static/background.svg', 'static/calling-logo.png', 'static/check.png', + 'static/user/home.png', 'static/user/home_no.png', 'static/wjw.png', 'static/ys.png', + 'static/yy.png', 'static/zs.jpg', 'training/static/footprint.svg'] +const sourceDirectory = path.join(PROJECT, 'native/tang-detective') +const receiptPath = path.join(PROJECT, 'build/tang-detective-media-prune-receipt.json') +if (fs.existsSync(receiptPath) || fs.existsSync(recoveryDirectory)) throw new Error('CLEANUP_ALREADY_STARTED_OR_COMPLETED') +const source = inventory(PROJECT) +const manifest = JSON.parse(fs.readFileSync(path.join(PROJECT, 'build/tang-detective-cos-manifest.json'))) +const media = loadCosMediaManifest({ sourceDirectory }) +const uploaded = JSON.parse(fs.readFileSync(path.join(PROJECT, 'build/tang-detective-admin-upload-receipt.json'))) +if (!media || uploaded.complete !== true || uploaded.runId !== media.uploadRunId + || uploaded.mediaManifestSha256 !== media.manifestSha256 || uploaded.objects.length !== media.objectCount) { + throw new Error('REAL_COMPLETE_UPLOAD_RECEIPT_REQUIRED') +} +for (const entry of media.entries.values()) { + const proof = uploaded.objects.find(item => item.url === entry.url) + if (!proof || proof.sha256 !== entry.sha256 || proof.bytes !== entry.bytes || proof.remoteVerifiedSha256 !== entry.sha256 + || proof.publicReadVerified !== true || proof.uploaded !== true + || (entry.kind === 'audio' && proof.rangeVerified !== true)) throw new Error('UPLOAD_PROOF_MISMATCH') +} +const archiveStat = fs.lstatSync(archive) +if (!archiveStat.isFile() || archiveStat.isSymbolicLink()) throw new Error('BACKUP_NOT_REGULAR_FILE') +const archiveBytes = fs.readFileSync(archive) +if (hash(archiveBytes) !== expectedArchiveSha256) throw new Error('BACKUP_ARCHIVE_CHANGED') +const targets = [...source.entries.map(entry => ({ projectPath: `native/tang-detective/${entry.sourcePath}`, + bytes: entry.bytes, sha256: entry.sha256, reason: 'verified-cos-replacement' })), +...unused.map(projectPath => { + const bytes = fs.readFileSync(path.join(PROJECT, projectPath)) + return { projectPath, bytes: bytes.length, sha256: hash(bytes), reason: 'no-runtime-reference' } +})] +if (targets.length !== 214 || new Set(targets.map(e => e.projectPath)).size !== 214) throw new Error('UNEXPECTED_CLEANUP_SET') +function verifyLocal(entry) { + let current = PROJECT + for (const part of entry.projectPath.split('/')) { + current = path.join(current, part) + if (fs.lstatSync(current).isSymbolicLink()) throw new Error('CLEANUP_SYMLINK_NOT_ALLOWED') + } + const stat = fs.lstatSync(current) + const bytes = fs.readFileSync(current) + if (!stat.isFile() || bytes.length !== entry.bytes || hash(bytes) !== entry.sha256) throw new Error('CLEANUP_SOURCE_CHANGED') +} +for (const entry of targets) { + verifyLocal(entry) + const result = spawnSync('tar', ['-xOf', archive, entry.projectPath], { maxBuffer: entry.bytes + 65536 }) + if (result.status !== 0 || result.stdout.length !== entry.bytes || hash(result.stdout) !== entry.sha256) { + throw new Error(`BACKUP_ENTRY_NOT_IDENTICAL: ${entry.projectPath}`) + } +} +const receipt = { schemaVersion: 1, status: 'completed', uploadRunId: media.uploadRunId, + sourceManifestSha256: media.sourceManifestSha256, mediaManifestSha256: media.manifestSha256, + backup: { sha256: expectedArchiveSha256, bytes: archiveBytes.length, format: 'tar.gz' }, + entries: [...media.entries.values()].map(({ sourcePath, bytes, sha256, objectKey, url }) => ({ + sourcePath, bytes, sha256, objectKey, url, backupSha256: expectedArchiveSha256 })), + additionalUnusedFiles: targets.filter(entry => entry.reason === 'no-runtime-reference'), + sourceMediaBytes: source.entries.reduce((n, e) => n + e.bytes, 0), + removedProjectBytes: targets.reduce((n, e) => n + e.bytes, 0), + method: 'moved-byte-identical-originals-outside-project-with-verified-archive', + contentRegenerated: false, cloudObjectsDeleted: false } +validatePruneReceipt(receipt, { source: readSourceManifest(), media }) +if (mode === 'check') { + console.log(JSON.stringify({ ready: true, files: targets.length, bytes: receipt.removedProjectBytes, + backupEntriesByteVerified: targets.length, originalsChanged: false })) +} else { + // Preflight every target again before the first move. On a failure, roll back only this task's exact paths. + targets.forEach(verifyLocal) + fs.mkdirSync(recoveryDirectory, { recursive: false }) + const moved = [] + try { + for (const entry of targets) { + verifyLocal(entry) + const to = path.join(recoveryDirectory, entry.projectPath) + fs.mkdirSync(path.dirname(to), { recursive: true }) + if (fs.existsSync(to)) throw new Error('RECOVERY_TARGET_EXISTS') + fs.renameSync(path.join(PROJECT, entry.projectPath), to) + moved.push(entry) + } + validateCosMediaManifest(manifest, { sourceDirectory, pruneReceipt: receipt }) + receipt.completedAt = new Date().toISOString() + fs.writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n', { flag: 'wx' }) + console.log(JSON.stringify({ completed: true, filesRemovedFromProject: moved.length, + bytesRemovedFromProject: receipt.removedProjectBytes, recoveryDirectory, archive, receiptPath })) + } catch (error) { + const recoveryFailures = [] + for (const entry of moved.reverse()) { + try { + const original = path.join(PROJECT, entry.projectPath) + if (fs.existsSync(original)) throw new Error('ORIGINAL_PATH_NOW_OCCUPIED') + fs.renameSync(path.join(recoveryDirectory, entry.projectPath), original) + } catch { recoveryFailures.push(entry.projectPath) } + } + if (recoveryFailures.length) console.error(JSON.stringify({ recoveryFailures, recoveryDirectory, archive })) + throw error + } +} diff --git a/TongjiUniApp/scripts/tang-cos/upload.mjs b/TongjiUniApp/scripts/tang-cos/upload.mjs new file mode 100644 index 0000000..aaef69b --- /dev/null +++ b/TongjiUniApp/scripts/tang-cos/upload.mjs @@ -0,0 +1,228 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { dependency, readCosConfig, safeError } from './config.mjs' + +export const PROJECT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const MEDIA = { + '.jpg': ['image', 'image/jpeg'], '.jpeg': ['image', 'image/jpeg'], '.png': ['image', 'image/png'], + '.webp': ['image', 'image/webp'], '.gif': ['image', 'image/gif'], '.svg': ['image', 'image/svg+xml'], + '.mp3': ['audio', 'audio/mpeg'], '.wav': ['audio', 'audio/wav'], '.m4a': ['audio', 'audio/mp4'], + '.aac': ['audio', 'audio/aac'], '.ogg': ['audio', 'audio/ogg'], '.mp4': ['video', 'video/mp4'], + '.webm': ['video', 'video/webm'], '.mov': ['video', 'video/quicktime'], +} +export const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex') + +function walk(directory, prefix = '') { + return fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap(entry => { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isSymbolicLink()) throw new Error('SOURCE_SYMLINK_NOT_ALLOWED') + return entry.isDirectory() ? walk(path.join(directory, entry.name), relative) : [relative] + }) +} + +export function inventory(project = PROJECT) { + const sourceDirectory = path.join(project, 'native/tang-detective') + const manifestBytes = fs.readFileSync(path.join(project, 'build/tang-detective-source-manifest.json')) + const source = JSON.parse(manifestBytes) + const files = walk(sourceDirectory) + const expected = new Map(source.files.map(entry => [entry.path, entry])) + if (files.length !== expected.size || files.some(file => !expected.has(file))) throw new Error('SOURCE_FILE_SET_CHANGED') + const entries = [] + for (const relative of files) { + const bytes = fs.readFileSync(path.join(sourceDirectory, relative)) + const sha256 = hash(bytes) + const original = expected.get(relative) + if (original.bytes !== bytes.length || original.sha256 !== sha256) throw new Error('SOURCE_BYTES_CHANGED') + const extension = path.extname(relative).toLowerCase() + if (!MEDIA[extension]) continue + const [kind, contentType] = MEDIA[extension] + entries.push({ sourcePath: relative, kind, contentType, bytes: bytes.length, sha256, + objectKey: `tang-detective/season-01/media-v1/${sha256}${extension}` }) + } + if (!entries.length) throw new Error('NO_SOURCE_MEDIA') + return { sourceDirectory, sourceManifestSha256: hash(manifestBytes), entries } +} + +function writeJson(filename, value) { + fs.mkdirSync(path.dirname(filename), { recursive: true }) + const temp = `${filename}.${process.pid}.tmp` + fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' }) + fs.renameSync(temp, filename) +} + +export function objectUrl(baseUrl, objectKey) { + if (!/^tang-detective\/season-01\/media-v1\/[a-f0-9]{64}\.[a-z0-9]+$/.test(objectKey)) { + throw new Error('UNSAFE_OBJECT_KEY') + } + const url = new URL(`${baseUrl}/${objectKey}`) + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) throw new Error('UNSAFE_OBJECT_URL') + return url.href +} + +async function boundedBody(response, limit) { + let length = 0 + const chunks = [] + for await (const chunk of response.body) { + length += chunk.length + if (length > limit) throw new Error('REMOTE_BODY_TOO_LARGE') + chunks.push(chunk) + } + return Buffer.concat(chunks) +} + +export async function verifyPublicObject(entry, fetchImpl = fetch) { + const response = await fetchImpl(entry.url, { redirect: 'error', signal: AbortSignal.timeout(20000) }) + if (response.status !== 200) { await response.body?.cancel(); throw new Error('PUBLIC_MEDIA_GET_FAILED') } + const bytes = await boundedBody(response, entry.bytes) + if (bytes.length !== entry.bytes || hash(bytes) !== entry.sha256) throw new Error('PUBLIC_MEDIA_HASH_MISMATCH') + if (response.headers.get('content-type')?.split(';')[0].trim() !== entry.contentType) throw new Error('PUBLIC_MEDIA_TYPE_MISMATCH') + let rangeVerified = null + if (entry.kind === 'audio' || entry.kind === 'video') { + const last = Math.min(1023, entry.bytes - 1) + const range = await fetchImpl(entry.url, { headers: { Range: `bytes=0-${last}` }, redirect: 'error', signal: AbortSignal.timeout(20000) }) + if (range.status !== 206 || range.headers.get('content-range') !== `bytes 0-${last}/${entry.bytes}`) { + await range.body?.cancel(); throw new Error('PUBLIC_MEDIA_RANGE_FAILED') + } + const fragment = await boundedBody(range, last + 1) + if (!fragment.equals(bytes.subarray(0, last + 1))) throw new Error('PUBLIC_MEDIA_RANGE_HASH_MISMATCH') + rangeVerified = true + } + return { remoteVerifiedSha256: hash(bytes), rangeVerified, verifiedAt: new Date().toISOString() } +} + +export async function uploadObject(cos, destination, entry, body, onState = () => {}) { + const params = { Bucket: destination.bucket, Region: destination.region, Key: entry.objectKey } + let exists = false + try { + const head = await cos.headObject(params) + if (Number(head.headers?.['content-length']) !== entry.bytes || head.headers?.['x-cos-meta-sha256'] !== entry.sha256) { + throw new Error('EXISTING_OBJECT_NOT_OWNED_OR_DIFFERENT') + } + exists = true + } catch (error) { + if (Number(error.statusCode) !== 404) throw error + } + if (!exists) { + onState({ uploaded: 'unknown', uploadStatus: 'put-started-outcome-unknown' }) + await cos.putObject({ ...params, Body: body, ContentLength: body.length, ContentType: entry.contentType, + CacheControl: 'public, max-age=31536000, immutable', Headers: { + 'Content-MD5': crypto.createHash('md5').update(body).digest('base64'), + 'x-cos-meta-sha256': entry.sha256, + 'x-cos-forbid-overwrite': 'true', + } }) + } + return { uploaded: true, uploadStatus: 'confirmed', action: exists ? 'reused-identical' : 'uploaded' } +} + +export async function main(mode, dependencies = {}) { + if (!['inventory', 'inspect', 'upload'].includes(mode)) throw new Error('USE_INVENTORY_INSPECT_OR_UPLOAD') + const project = dependencies.project || PROJECT + const readConfig = dependencies.readConfig || readCosConfig + const verify = dependencies.verify || verifyPublicObject + const log = dependencies.log || (value => console.log(JSON.stringify(value))) + if (mode === 'upload') return runUpload({ project, readConfig, verify, log, createCos: dependencies.createCos }) + const input = inventory(project) + const summary = { files: input.entries.length, uniqueObjects: new Set(input.entries.map(e => e.objectKey)).size, + bytes: input.entries.reduce((sum, entry) => sum + entry.bytes, 0), + kinds: Object.fromEntries(['image', 'audio', 'video'].map(kind => [kind, input.entries.filter(e => e.kind === kind).length])) } + if (mode === 'inventory') { log(summary); return } + const config = await readConfig(path.resolve(project, '../server')) + const destination = { bucket: config.bucket, region: config.region, baseUrl: config.baseUrl } + log({ ...summary, destination, credentialsPresent: true, databaseReadOnly: true, tlsVerified: true }) +} + +async function runUpload({ project, readConfig, verify, log, createCos }) { + const runId = crypto.randomUUID() + const receipt = { schemaVersion: 1, runId, startedAt: new Date().toISOString(), phase: 'inventory', + complete: false, bucketPermissionsChanged: false, originalFilesChanged: false, objects: [] } + const receiptPath = path.join(project, 'build/tang-detective-cos-upload-receipt.json') + const historyDirectory = path.join(project, 'build/tang-detective-cos-upload-attempts') + fs.mkdirSync(historyDirectory, { recursive: true }) + if (fs.existsSync(receiptPath)) { + const previous = fs.readFileSync(receiptPath) + const archive = path.join(historyDirectory, `previous-${hash(previous)}.json`) + if (!fs.existsSync(archive)) fs.writeFileSync(archive, previous, { flag: 'wx' }) + } + const persist = () => { + writeJson(path.join(historyDirectory, `${runId}.json`), receipt) + writeJson(receiptPath, receipt) + } + persist() + const completed = new Map() + try { + const input = inventory(project) + const summary = { files: input.entries.length, uniqueObjects: new Set(input.entries.map(e => e.objectKey)).size, + bytes: input.entries.reduce((sum, entry) => sum + entry.bytes, 0) } + receipt.sourceManifestSha256 = input.sourceManifestSha256 + receipt.phase = 'configuration' + persist() + const config = await readConfig(path.resolve(project, '../server')) + const destination = { bucket: config.bucket, region: config.region, baseUrl: config.baseUrl } + receipt.destination = destination + receipt.phase = 'sdk-initialization' + persist() + const options = { SecretId: config.access_key, SecretKey: config.secret_key, Protocol: 'https:', + Timeout: 20000, MaxRetryTimes: 0, UploadCheckContentMd5: true } + const cos = createCos ? createCos(options) : new (dependency('cos-nodejs-sdk-v5'))(options) + log({ ...summary, destination, credentialsPresent: true, databaseReadOnly: true, tlsVerified: true }) + receipt.phase = 'upload-and-verify' + // Sequential first-object verification stops immediately if this bucket/domain isn't anonymously readable. + for (const item of input.entries) { + if (completed.has(item.objectKey)) continue + const entry = { ...item, url: objectUrl(config.baseUrl, item.objectKey) } + const body = fs.readFileSync(path.join(input.sourceDirectory, item.sourcePath)) + if (body.length !== item.bytes || hash(body) !== item.sha256) throw new Error('SOURCE_CHANGED_DURING_UPLOAD') + const result = { ...entry, uploaded: false, uploadStatus: 'not-attempted' } + receipt.objects.push(result) + persist() + try { + Object.assign(result, await uploadObject(cos, destination, entry, body, state => { + Object.assign(result, state) + persist() + })) + persist() + Object.assign(result, await verify(entry)) + completed.set(item.objectKey, result) + persist() + } catch (error) { + result.error = safeError(error) + // A lost PUT response is not proof that nothing was uploaded. Read-only reconciliation can establish it. + if (result.uploaded === 'unknown') { + try { + const head = await cos.headObject({ Bucket: destination.bucket, Region: destination.region, Key: entry.objectKey }) + if (Number(head.headers?.['content-length']) === entry.bytes && head.headers?.['x-cos-meta-sha256'] === entry.sha256) { + Object.assign(result, { uploaded: true, uploadStatus: 'confirmed-by-readback' }, await verify(entry)) + } + } catch { /* Keep unknown; retry checks the exact content-addressed key without overwriting. */ } + } + throw error + } + log({ verified: completed.size, total: summary.uniqueObjects }) + } + // Recheck all source bytes immediately before publishing the build activation manifest. + if (inventory(project).sourceManifestSha256 !== input.sourceManifestSha256) throw new Error('SOURCE_MANIFEST_CHANGED') + const entries = input.entries.map(entry => ({ ...entry, url: completed.get(entry.objectKey).url, uploaded: true, publicReadVerified: true, + remoteVerifiedSha256: completed.get(entry.objectKey).remoteVerifiedSha256, + rangeVerified: completed.get(entry.objectKey).rangeVerified, + verifiedAt: completed.get(entry.objectKey).verifiedAt })) + writeJson(path.join(project, 'build/tang-detective-cos-manifest.json'), { + schemaVersion: 1, uploadRunId: runId, sourceManifestSha256: input.sourceManifestSha256, destination, entries, + }) + receipt.complete = true + receipt.phase = 'complete' + receipt.completedAt = new Date().toISOString() + log({ complete: true, ...summary }) + } catch (error) { + receipt.error = safeError(error) + receipt.failedAt = new Date().toISOString() + throw error + } finally { + persist() + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + main(process.argv[2]).catch(error => { console.error(JSON.stringify({ complete: false, error: safeError(error) })); process.exitCode = 1 }) +} diff --git a/TongjiUniApp/scripts/tang-cos/upload.test.mjs b/TongjiUniApp/scripts/tang-cos/upload.test.mjs new file mode 100644 index 0000000..f1bf2eb --- /dev/null +++ b/TongjiUniApp/scripts/tang-cos/upload.test.mjs @@ -0,0 +1,203 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { inventory, hash, objectUrl, verifyPublicObject, uploadObject, main } from './upload.mjs' +import { configuredDatabase, validateCosConfig, safeError, withDeadline } from './config.mjs' + +function fixture(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'tang-cos-test-')) + t.after(() => fs.rmSync(directory, { recursive: true })) + fs.mkdirSync(path.join(directory, 'native/tang-detective'), { recursive: true }) + fs.mkdirSync(path.join(directory, 'build')) + fs.writeFileSync(path.join(directory, 'native/tang-detective/image.jpg'), 'existing-image') + fs.writeFileSync(path.join(directory, 'build/tang-detective-source-manifest.json'), JSON.stringify({ files: [ + { path: 'image.jpg', bytes: 14, sha256: hash('existing-image') }, + ] })) + return directory +} + +test('inventory creates content-addressed keys without changing source files', t => { + const project = fixture(t) + const input = inventory(project) + assert.equal(input.entries.length, 1) + assert.equal(input.entries[0].objectKey, `tang-detective/season-01/media-v1/${hash('existing-image')}.jpg`) + assert.equal(fs.readFileSync(path.join(project, 'native/tang-detective/image.jpg'), 'utf8'), 'existing-image') +}) + +test('inventory refuses changed bytes, unrecorded files, and symlinks', t => { + const project = fixture(t) + const source = path.join(project, 'native/tang-detective') + fs.writeFileSync(path.join(source, 'image.jpg'), 'different-data') + assert.throws(() => inventory(project), /SOURCE_BYTES_CHANGED/) + fs.writeFileSync(path.join(source, 'image.jpg'), 'existing-image') + fs.writeFileSync(path.join(source, 'extra.mp3'), 'audio') + assert.throws(() => inventory(project), /SOURCE_FILE_SET_CHANGED/) + fs.unlinkSync(path.join(source, 'extra.mp3')) + fs.symlinkSync(path.join(source, 'image.jpg'), path.join(source, 'linked.jpg')) + assert.throws(() => inventory(project), /SOURCE_SYMLINK_NOT_ALLOWED/) +}) + +test('database config never disables certificate or hostname verification', t => { + const server = fs.mkdtempSync(path.join(os.tmpdir(), 'tang-cos-db-test-')) + t.after(() => fs.rmSync(server, { recursive: true })) + fs.mkdirSync(path.join(server, 'config')) + const values = { hostname: 'db.example.invalid', hostport: '3306', database: 'test', username: 'test', password: 'fake-not-secret', prefix: 'zyt_' } + fs.writeFileSync(path.join(server, 'config/database.php'), Object.entries(values).map(([key, value]) => `env('database.${key}', '${value}')`).join('\n')) + const config = configuredDatabase(server, {}) + assert.equal(config.options.ssl.rejectUnauthorized, true) + assert.equal(config.options.ssl.verifyIdentity, true) + assert.equal(config.options.multipleStatements, false) + fs.writeFileSync(path.join(server, '.env'), 'unparsed') + assert.throws(() => configuredDatabase(server, {}), /SERVER_ENV_REQUIRES_NATIVE_RUNTIME/) +}) + +test('destination rejects insecure/signed URLs and missing credentials', () => { + const config = { bucket: 'example-12345', region: 'ap-guangzhou', access_key: 'test', secret_key: 'test' } + assert.equal(validateCosConfig(config, 'qcloud').baseUrl, 'https://example-12345.cos.ap-guangzhou.myqcloud.com') + for (const domain of ['http://example.invalid', 'https://user:password@example.invalid', 'https://example.invalid/?token=test']) { + assert.throws(() => validateCosConfig({ ...config, domain }, 'qcloud'), /COS_REQUIRES_UNSIGNED_HTTPS_BASE_URL/) + } + assert.throws(() => validateCosConfig(config, 'local'), /CONFIGURED_DRIVER_IS_NOT_COS/) + assert.throws(() => validateCosConfig({ ...config, secret_key: '' }, 'qcloud'), /COS_CREDENTIALS_MISSING/) +}) + +test('object URLs only use namespaced content-addressed keys', () => { + const key = `tang-detective/season-01/media-v1/${hash('image')}.jpg` + assert.equal(objectUrl('https://example.invalid/prefix', key), `https://example.invalid/prefix/${key}`) + assert.throws(() => objectUrl('https://example.invalid', '../unrelated.jpg'), /UNSAFE_OBJECT_KEY/) + assert.throws(() => objectUrl('http://example.invalid', key), /UNSAFE_OBJECT_URL/) +}) + +test('public verification checks complete bytes, MIME, hash and audio Range', async () => { + const bytes = Buffer.from('original-audio') + const entry = { url: 'https://example.invalid/file.mp3', bytes: bytes.length, sha256: hash(bytes), kind: 'audio', contentType: 'audio/mpeg' } + const calls = [] + const verified = await verifyPublicObject(entry, async (url, options) => { + calls.push(options) + assert.equal(options.redirect, 'error') + assert.equal(options.headers?.Authorization, undefined) + return new Response(bytes, options.headers?.Range ? { status: 206, headers: { 'content-range': `bytes 0-13/14` } } + : { status: 200, headers: { 'content-type': 'audio/mpeg' } }) + }) + assert.equal(verified.remoteVerifiedSha256, entry.sha256) + assert.equal(verified.rangeVerified, true) + assert.equal(calls.length, 2) +}) + +test('private objects, oversize bodies, corrupt data and missing Range cannot activate a manifest', async () => { + const entry = { url: 'https://example.invalid/file.mp3', bytes: 4, sha256: hash('good'), kind: 'audio', contentType: 'audio/mpeg' } + await assert.rejects(verifyPublicObject(entry, async () => new Response('denied', { status: 403 })), /PUBLIC_MEDIA_GET_FAILED/) + await assert.rejects(verifyPublicObject(entry, async () => new Response('too-large')), /REMOTE_BODY_TOO_LARGE/) + await assert.rejects(verifyPublicObject(entry, async () => new Response('oops')), /PUBLIC_MEDIA_HASH_MISMATCH/) + await assert.rejects(verifyPublicObject(entry, async () => new Response('good', { headers: { 'content-type': 'audio/mpeg' } })), /PUBLIC_MEDIA_RANGE_FAILED/) +}) + +test('safe errors never leak raw connection or signed-URL text', () => { + assert.equal(safeError({ message: 'mysql://user:password@database/' }), 'REDACTED_OPERATION_ERROR') + assert.equal(safeError({ code: 'HANDSHAKE_SSL_ERROR', message: 'secret' }), 'HANDSHAKE_SSL_ERROR') + assert.equal(safeError({ message: 'https://bucket/?secret=test' }), 'REDACTED_OPERATION_ERROR') +}) + +test('uploader only creates missing objects with checksums and never changes permissions', async () => { + const body = Buffer.from('good') + const entry = { bytes: body.length, sha256: hash(body), objectKey: `tang-detective/season-01/media-v1/${hash(body)}.jpg`, contentType: 'image/jpeg' } + const destination = { bucket: 'example-12345', region: 'ap-guangzhou' } + let put = null + const cos = { headObject: async () => { throw { statusCode: 404 } }, putObject: async params => { put = params } } + const result = await uploadObject(cos, destination, entry, body) + assert.equal(result.action, 'uploaded') + assert.equal(put.Headers['x-cos-forbid-overwrite'], 'true') + assert.equal(put.Headers['x-cos-meta-sha256'], entry.sha256) + assert.equal(put.Headers['Content-MD5'], 'dV+FwnI7s5OBxzeaYEFg2A==') + assert.equal(put.ACL, undefined) + assert.equal(put.Headers['x-cos-acl'], undefined) + assert.equal(put.ContentType, 'image/jpeg') +}) + +test('uploader reuses exact owned objects; mismatches and denied HEAD never trigger writes', async () => { + const entry = { bytes: 4, sha256: hash('good'), objectKey: 'test', contentType: 'image/jpeg' } + let writes = 0 + const cos = { headObject: async () => ({ headers: { 'content-length': '4', 'x-cos-meta-sha256': hash('good') } }), putObject: async () => { writes++ } } + assert.equal((await uploadObject(cos, {}, entry, Buffer.from('good'))).action, 'reused-identical') + cos.headObject = async () => ({ headers: { 'content-length': '4', 'x-cos-meta-sha256': hash('evil') } }) + await assert.rejects(uploadObject(cos, {}, entry, Buffer.from('good')), /EXISTING_OBJECT_NOT_OWNED_OR_DIFFERENT/) + cos.headObject = async () => { throw { statusCode: 403 } } + await assert.rejects(uploadObject(cos, {}, entry, Buffer.from('good'))) + assert.equal(writes, 0) +}) + +test('database deadline aborts a stalled task-owned operation and clears its timer', async () => { + let aborts = 0 + await assert.rejects(withDeadline(new Promise(() => {}), 5, () => { aborts++ }, 'DATABASE_QUERY_TIMEOUT'), /DATABASE_QUERY_TIMEOUT/) + assert.equal(aborts, 1) + assert.equal(await withDeadline(Promise.resolve('done'), 5, () => { aborts++ }, 'DATABASE_QUERY_TIMEOUT'), 'done') + assert.equal(aborts, 1) +}) + +const fakeConfig = () => ({ bucket: 'example-12345', region: 'ap-guangzhou', baseUrl: 'https://example.invalid', + access_key: 'FAKE_ACCESS_MUST_STAY_IN_MEMORY', secret_key: 'FAKE_SECRET_MUST_STAY_IN_MEMORY' }) +const readReceipt = project => JSON.parse(fs.readFileSync(path.join(project, 'build/tang-detective-cos-upload-receipt.json'))) + +test('a configuration failure creates a fresh failed attempt and archives previous success', async t => { + const project = fixture(t) + const previous = { complete: true, runId: 'previous-run', objects: [] } + fs.writeFileSync(path.join(project, 'build/tang-detective-cos-upload-receipt.json'), JSON.stringify(previous)) + await assert.rejects(main('upload', { project, log: () => {}, readConfig: async () => { throw { code: 'HANDSHAKE_SSL_ERROR' } } })) + const latest = readReceipt(project) + assert.equal(latest.complete, false) + assert.notEqual(latest.runId, previous.runId) + assert.equal(latest.phase, 'configuration') + assert.equal(latest.error, 'HANDSHAKE_SSL_ERROR') + assert.deepEqual(latest.objects, []) + const archived = fs.readdirSync(path.join(project, 'build/tang-detective-cos-upload-attempts')) + assert.equal(archived.length, 2) + assert.equal(fs.existsSync(path.join(project, 'build/tang-detective-cos-manifest.json')), false) +}) + +test('SDK initialization failure is recorded without exposing credentials', async t => { + const project = fixture(t) + await assert.rejects(main('upload', { project, readConfig: async () => fakeConfig(), log: () => {}, + createCos: () => { throw new Error('signed-url-or-secret-detail') } })) + const latest = readReceipt(project) + assert.equal(latest.complete, false) + assert.equal(latest.phase, 'sdk-initialization') + assert.equal(latest.error, 'REDACTED_OPERATION_ERROR') + assert.equal(JSON.stringify(latest).includes('MUST_STAY_IN_MEMORY'), false) +}) + +test('lost PUT response persists unknown outcome and only performs read-only reconciliation', async t => { + const project = fixture(t) + let puts = 0 + let heads = 0 + await assert.rejects(main('upload', { project, readConfig: async () => fakeConfig(), log: () => {}, createCos: () => ({ + headObject: async () => { heads++; throw { statusCode: heads === 1 ? 404 : 403 } }, + putObject: async () => { + puts++ + assert.equal(readReceipt(project).objects[0].uploaded, 'unknown') + throw { code: 'ETIMEDOUT' } + }, + }) })) + const latest = readReceipt(project) + assert.equal(latest.complete, false) + assert.equal(latest.objects[0].uploaded, 'unknown') + assert.equal(heads, 2) + assert.equal(puts, 1) + assert.equal(fs.existsSync(path.join(project, 'build/tang-detective-cos-manifest.json')), false) +}) + +test('only a fully verified upload writes the activation manifest; no secret values reach artifacts', async t => { + const project = fixture(t) + await main('upload', { project, readConfig: async () => fakeConfig(), log: () => {}, createCos: () => ({ + headObject: async () => { throw { statusCode: 404 } }, putObject: async () => {}, + }), verify: async entry => ({ remoteVerifiedSha256: entry.sha256, rangeVerified: null, verifiedAt: 'test-only' }) }) + const latest = readReceipt(project) + const manifest = JSON.parse(fs.readFileSync(path.join(project, 'build/tang-detective-cos-manifest.json'))) + assert.equal(latest.complete, true) + assert.equal(manifest.entries.length, 1) + assert.equal(manifest.uploadRunId, latest.runId) + assert.equal(manifest.entries[0].publicReadVerified, true) + assert.equal(manifest.entries[0].remoteVerifiedSha256, hash('existing-image')) + assert.equal(JSON.stringify([latest, manifest]).includes('MUST_STAY_IN_MEMORY'), false) +}) diff --git a/TongjiUniApp/scripts/tang-cos/verify-admin-upload.mjs b/TongjiUniApp/scripts/tang-cos/verify-admin-upload.mjs new file mode 100644 index 0000000..6351d01 --- /dev/null +++ b/TongjiUniApp/scripts/tang-cos/verify-admin-upload.mjs @@ -0,0 +1,109 @@ +import fs from 'node:fs' +import path from 'node:path' +import { inventory, hash, verifyPublicObject, PROJECT } from './upload.mjs' +import { validateCosMediaManifest, loadCosMediaManifest } from '../../build/tang-detective-cos-media.mjs' + +const json = value => JSON.stringify(value, null, 2) + '\n' +const write = (file, value) => { + const temp = `${file}.${process.pid}.tmp` + fs.writeFileSync(temp, json(value), { flag: 'wx' }) + fs.renameSync(temp, file) +} +const destination = { bucket: 'gz-1349751149', region: 'ap-guangzhou', + baseUrl: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com' } +const mode = process.argv[2] +if (!['activate', 'audit'].includes(mode)) throw new Error('USE_ACTIVATE_OR_AUDIT') +const sourceDirectory = path.join(PROJECT, 'native/tang-detective') +const manifestPath = path.join(PROJECT, 'build/tang-detective-cos-manifest.json') +let manifest +if (mode === 'activate') { + if (fs.existsSync(manifestPath)) throw new Error('ACTIVATION_MANIFEST_EXISTS_USE_AUDIT') + const input = inventory(PROJECT) // No missing source or changed original can activate a new mapping. + const plan = JSON.parse(fs.readFileSync(path.join(PROJECT, 'build/tang-detective-admin-upload-plan.json'))) + const observed = JSON.parse(fs.readFileSync(path.join(PROJECT, 'build/tang-detective-admin-upload-observations.json'))) + if (plan.uploadRunId !== observed.uploadRunId || plan.sourceManifestSha256 !== input.sourceManifestSha256 + || observed.baseUrl !== destination.baseUrl || observed.images.length !== 135 || observed.audio.length !== 44 + || observed.imageDirectory !== 'uploads/images/20260908/' || observed.audioDirectory !== 'uploads/voice/20260908/') { + throw new Error('OBSERVATION_PLAN_BINDING_FAILED') + } + const byHash = new Map() + for (const entry of plan.entries) { + let filename, directory + if (!entry.stagedName) { + filename = observed.firstImage + directory = observed.imageDirectory + if (entry.observedUrl !== `${observed.baseUrl}/${directory}${filename}`) throw new Error('FIRST_SAMPLE_CHANGED') + } else { + const match = /^tang-20260908-1714-(image|audio)-(\d{3})\.(jpg|mp3)$/.exec(entry.stagedName) + if (!match || match[1] !== entry.kind) throw new Error('INVALID_STAGED_NAME') + const i = Number(match[2]) - 1 + filename = (entry.kind === 'image' ? observed.images : observed.audio)[i] + directory = entry.kind === 'image' ? observed.imageDirectory : observed.audioDirectory + } + if (!/^[a-z0-9.-]+$/.test(filename || '')) throw new Error('UNSAFE_OBSERVED_FILENAME') + byHash.set(entry.sha256, { ...entry, objectKey: directory + filename, url: `${observed.baseUrl}/${directory}${filename}` }) + } + if (byHash.size !== 180) throw new Error('INCOMPLETE_PLAN') + manifest = { schemaVersion: 2, uploadRunId: plan.uploadRunId, sourceManifestSha256: input.sourceManifestSha256, + destination, entries: input.entries.map(entry => { + const mapping = byHash.get(entry.sha256) + if (!mapping || mapping.bytes !== entry.bytes || mapping.contentType !== entry.contentType) throw new Error('PLAN_BYTES_CHANGED') + return { ...entry, objectKey: mapping.objectKey, url: mapping.url } + }) } +} else { + loadCosMediaManifest({ sourceDirectory }) // Validate source, receipt and complete existing binding first. + manifest = JSON.parse(fs.readFileSync(manifestPath)) +} +const unique = [...new Map(manifest.entries.map(entry => [entry.url, entry])).values()] +const receipt = { schemaVersion: 1, runId: manifest.uploadRunId, mode, startedAt: new Date().toISOString(), + phase: 'public-readback', complete: false, uploadTransport: 'authenticated-existing-admin-ui', + destination, sourceManifestSha256: manifest.sourceManifestSha256, bucketPermissionsChanged: false, + originalFilesChanged: false, objects: [] } +const receiptPath = path.join(PROJECT, `build/tang-detective-admin-${mode === 'activate' ? 'upload' : 'audit'}-receipt.json`) +write(receiptPath, receipt) +try { + // Each unsigned GET is independent. Four workers, no access token, database connection or upload here. + let cursor = 0 + const outcomes = await Promise.allSettled(Array.from({ length: 4 }, async () => { + for (;;) { + const i = cursor++ + if (i >= unique.length) return + const entry = unique[i] + const url = new URL(entry.url) + if (url.origin !== destination.baseUrl || url.search || url.hash || url.username || url.password + || !/^\/uploads\/(images|voice)\/20260908\/[a-z0-9.-]+$/.test(url.pathname)) throw new Error('UNSAFE_READBACK_URL') + const verified = await verifyPublicObject(entry) + receipt.objects.push({ ...entry, ...verified, uploaded: true, publicReadVerified: true }) + write(receiptPath, receipt) + if (receipt.objects.length % 20 === 0) console.log(JSON.stringify({ verified: receipt.objects.length, total: unique.length })) + } + })) + const failure = outcomes.find(outcome => outcome.status === 'rejected') + if (failure) throw failure.reason // All task-owned requests have settled before recording failure. + const verified = new Map(receipt.objects.map(entry => [entry.url, entry])) + const completeManifest = { ...manifest, entries: manifest.entries.map(entry => { + const proof = verified.get(entry.url) + return { ...entry, uploaded: true, publicReadVerified: true, remoteVerifiedSha256: proof.remoteVerifiedSha256, + rangeVerified: proof.rangeVerified, verifiedAt: proof.verifiedAt } + }) } + // Canonical receipt binds the original activation manifest. Audit never changes it or prune hashes. + if (mode === 'activate') { + validateCosMediaManifest(completeManifest, { sourceDirectory }) + inventory(PROJECT) + fs.writeFileSync(manifestPath, json(completeManifest), { flag: 'wx' }) + } + receipt.complete = true + receipt.phase = 'complete' + receipt.mediaManifestSha256 = hash(fs.readFileSync(manifestPath)) + receipt.completedAt = new Date().toISOString() + write(receiptPath, receipt) + console.log(JSON.stringify({ complete: true, uniqueObjects: unique.length, sourceMediaPaths: manifest.entries.length, + allBytesHashesTypesVerified: true, audioRangeVerified: unique.filter(e => e.kind === 'audio').length, + mode, manifestPath })) +} catch (error) { + receipt.phase = 'failed' + receipt.failedAt = new Date().toISOString() + receipt.errorCode = 'PUBLIC_READBACK_OR_MANIFEST_VALIDATION_FAILED' + write(receiptPath, receipt) + throw error +} diff --git a/TongjiUniApp/scripts/tang-cos/verify-cleaned-checkout.mjs b/TongjiUniApp/scripts/tang-cos/verify-cleaned-checkout.mjs new file mode 100644 index 0000000..55e78d5 --- /dev/null +++ b/TongjiUniApp/scripts/tang-cos/verify-cleaned-checkout.mjs @@ -0,0 +1,59 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { PROJECT, hash } from './upload.mjs' +import { loadCosMediaManifest, isMediaFile } from '../../build/tang-detective-cos-media.mjs' +import { validateNativeOutput } from '../../build/validate-tang-detective-output.mjs' + +const sourceDirectory = path.join(PROJECT, 'native/tang-detective') +const media = loadCosMediaManifest({ sourceDirectory }) +if (!media || media.sourceSnapshot.actual.size !== 269 || media.sourceSnapshot.missingMedia.length !== 204 + || !media.sourceSnapshot.prune) throw new Error('FULLY_CLEANED_CHECKOUT_REQUIRED') +const receiptFile = path.join(PROJECT, 'build/tang-detective-media-prune-receipt.json') +const receipt = JSON.parse(fs.readFileSync(receiptFile)) +const recoveryDirectory = '/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/pruned-originals' +const removed = [...receipt.entries.map(entry => ({ ...entry, projectPath: `native/tang-detective/${entry.sourcePath}` })), + ...receipt.additionalUnusedFiles] +for (const entry of removed) { + if (fs.existsSync(path.join(PROJECT, entry.projectPath))) throw new Error('REMOVED_PROJECT_FILE_REAPPEARED') + const bytes = fs.readFileSync(path.join(recoveryDirectory, entry.projectPath)) + if (bytes.length !== entry.bytes || hash(bytes) !== entry.sha256) throw new Error('RECOVERY_BYTES_CHANGED') +} +const report = { schemaVersion: 1, startedAt: new Date().toISOString(), passed: false, + uploadRunId: media.uploadRunId, sourceManifestSha256: media.sourceManifestSha256, + mediaManifestSha256: media.manifestSha256, pruneReceiptSha256: hash(fs.readFileSync(receiptFile)), + nonMediaSourceFilesByteVerified: 269, sourceMediaFilesRemaining: 0, + removedProjectFiles: removed.length, removedProjectBytes: receipt.removedProjectBytes, + recoveryFilesByteVerified: removed.length, commands: [], + boundary: 'Actual cleaned checkout, installed compiler and offline automated tests only. Not real-device, medical, backend deployment or release acceptance.' } +const outputFile = path.join(PROJECT, 'build/tang-detective-cleanup-verification.json') +const save = () => fs.writeFileSync(outputFile, JSON.stringify(report, null, 2) + '\n') +save() +for (const [command, args] of [ + ['npm', ['run', 'test:tang']], + ['npm', ['run', 'build:mp-weixin']], + ['npm', ['run', 'check:tang-output']], + [process.execPath, ['scripts/check-tang-native-compiler.cjs']], +]) { + const result = spawnSync(command, args, { cwd: PROJECT, encoding: 'utf8', timeout: 120000, maxBuffer: 16 * 1024 * 1024 }) + report.commands.push({ command: [command, ...args], exitCode: result.status, signal: result.signal, + stdout: result.stdout, stderr: result.stderr, passed: result.status === 0 && !result.error }) + save() + console.log(JSON.stringify({ command: [command, ...args], exitCode: result.status })) + if (!report.commands.at(-1).passed) throw new Error('POST_CLEANUP_COMMAND_FAILED') +} +report.output = validateNativeOutput(path.join(PROJECT, 'dist/build/mp-weixin')) +const walk = (dir, prefix = '') => fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + if (['node_modules', 'dist', 'unpackage', '.git'].includes(entry.name)) return [] + const relative = prefix + entry.name + if (entry.isSymbolicLink()) throw new Error('UNEXPECTED_OUTPUT_SYMLINK') + return entry.isDirectory() ? walk(path.join(dir, entry.name), relative + '/') : [relative] +}) +report.remainingHostMedia = walk(PROJECT).filter(file => !/^(node_modules|dist|unpackage|\.git)\//.test(file) && isMediaFile(file)) +report.passed = report.output.passed && report.output.mediaMode === 'cos' && report.output.packagedMediaFiles === 0 +report.completedAt = new Date().toISOString() +save() +console.log(JSON.stringify({ passed: report.passed, sourceMediaFilesRemaining: 0, packagedTangMediaFiles: report.output.packagedMediaFiles, + totalBuildBytes: report.output.outputSizes.totalFileBytes, mainPackageBytes: report.output.outputSizes.mainPackageFileBytes, + remainingHostMedia: report.remainingHostMedia, outputFile })) +if (!report.passed) process.exitCode = 1 diff --git a/TongjiUniApp/scripts/test-tang-page-lifecycle.cjs b/TongjiUniApp/scripts/test-tang-page-lifecycle.cjs new file mode 100644 index 0000000..ff9c2bf --- /dev/null +++ b/TongjiUniApp/scripts/test-tang-page-lifecycle.cjs @@ -0,0 +1,280 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') + +const root = path.resolve(__dirname, '..') +const native = path.join(root, 'native/tang-detective') +const adapter = path.join(root, 'native-adapter/tang-detective') +const settle = () => new Promise(resolve => setImmediate(resolve)) + +function fixture(relative, customOptions) { + let page + let finishBoot + let timerId = 0 + const state = { scope: 'account-A', events: [], audio: [], timers: new Map(), updates: 0, saves: [], resets: [], modals: [], redirects: [] } + const boot = new Promise(resolve => { finishBoot = resolve }) + const bridge = { getScope: () => state.scope, open: () => boot, flush: () => state.events.push('flush') } + const storage = { + getProgress: () => ({ completedHotspots: {}, completedChapters: [], lastChapter: 1, collectedMemoryCards: [], comicReaderByChapter: {} }), + saveProgress: value => { state.saves.push({ kind: 'progress', scope: state.scope, value }); return true }, + getSettings: () => ({ fontScale: 'large', sound: true }), + saveSettings: () => true, + getAudioProgress: () => ({}), + saveAudioProgress: value => { state.saves.push({ kind: 'audio', scope: state.scope, value }); return true }, + resetStoryProgress: scope => { state.resets.push(scope); return scope === state.scope }, + } + const beginHandlers = new Set() + const endHandlers = new Set() + const wx = { + env: { USER_DATA_PATH: '' }, + getWindowInfo: () => ({ windowWidth: 812, windowHeight: 375, screenWidth: 812, screenHeight: 375, safeArea: { top: 0, left: 0, right: 812, bottom: 375 } }), + getMenuButtonBoundingClientRect: () => ({ top: 8, bottom: 40, left: 724, right: 804, width: 80, height: 32 }), + pageScrollTo() {}, + reLaunch: value => state.redirects.push(value), + redirectTo: value => state.redirects.push(value), + navigateTo: value => state.redirects.push(value), + showToast: value => state.events.push(value.title), + showModal: value => state.modals.push(value), + onAudioInterruptionBegin: handler => beginHandlers.add(handler), + onAudioInterruptionEnd: handler => endHandlers.add(handler), + offAudioInterruptionBegin: handler => { beginHandlers.delete(handler); state.events.push('unbind-begin') }, + offAudioInterruptionEnd: handler => { endHandlers.delete(handler); state.events.push('unbind-end') }, + createInnerAudioContext() { + const audio = { handlers: {}, duration: 90, currentTime: 0, playbackRate: 1, plays: 0, pauses: 0, destroys: 0, + play() { this.plays += 1 }, pause() { this.pauses += 1 }, stop() {}, + seek(value) { this.currentTime = value }, destroy() { this.destroys += 1 } } + for (const name of ['Canplay', 'Play', 'Pause', 'Stop', 'Waiting', 'TimeUpdate', 'Ended', 'Error', 'Seeked']) { + audio[`on${name}`] = handler => { audio.handlers[name] = handler } + } + state.audio.push(audio) + return audio + }, + } + const cache = new Map() + function capture(options) { + page = { ...options, data: JSON.parse(JSON.stringify(options.data || {})), setData(values) { + state.updates += 1 + for (const [key, value] of Object.entries(values)) { + const parts = key.split('.') + let target = this.data + for (const part of parts.slice(0, -1)) target = target[part] || (target[part] = {}) + target[parts.at(-1)] = value + } + } } + } + let register + function load(filename, isPage = false) { + filename = path.resolve(filename) + if (filename.endsWith('/utils/storage.js')) return storage + if (filename.endsWith('/utils/platformBridge.js')) return bridge + if (cache.has(filename)) return cache.get(filename).exports + const module = { exports: {} } + cache.set(filename, module) + let source = fs.readFileSync(filename, 'utf8') + if (isPage) source = source.replace(/^Page\(\{/m, 'registerTangPage({') + vm.runInNewContext(source, { + module, exports: module.exports, Page: capture, registerTangPage: register, wx, + getCurrentPages: () => [page], + setTimeout: callback => { const id = ++timerId; state.timers.set(id, callback); return id }, + clearTimeout: id => { state.timers.delete(id); state.events.push(`clear:${id}`) }, + require(specifier) { + let dependency = path.resolve(path.dirname(filename), specifier) + if (!path.extname(dependency)) dependency += '.js' + // Catalog uses the final route adapter; all chapter/player helpers and + // data are real original modules, with only platform I/O mocked. + if (!fs.existsSync(dependency) && dependency.startsWith(adapter + path.sep)) { + dependency = path.join(native, path.relative(adapter, dependency)) + } + return load(dependency) + }, + }, { filename }) + return module.exports + } + register = load(path.join(adapter, 'utils/tangPage.js')) + if (customOptions) register(customOptions(state)) + else load(path.join(relative.startsWith('pages/catalog/') ? adapter : native, relative), true) + return { page, state, bridge, finishBoot, beginHandlers, endHandlers } +} + +async function open(f, query = {}) { + f.page.onLoad(query) + f.page.onShow() + f.page.onReady() + f.finishBoot() + await settle() +} + +test('deferred lifecycle delivers onLoad -> onShow -> onReady once and guards custom onX events', async () => { + const f = fixture(null, state => ({ + onLoad() { state.events.push('load') }, onShow() { state.events.push('show') }, onReady() { state.events.push('ready') }, + onAudioTimeUpdate() { state.events.push('audio-event') }, + })) + f.page.onLoad({}) + f.page.onShow() + f.page.onReady() + f.page.onAudioTimeUpdate() + f.page.onHide() + f.page.onShow() + f.finishBoot() + await settle() + assert.deepEqual(f.state.events, ['flush', 'load', 'show', 'ready']) + f.page.onReady() + f.page.onAudioTimeUpdate() + assert.equal(f.state.events.at(-1), 'audio-event') + f.page.onHide() + const count = f.state.events.length + f.page.onAudioTimeUpdate() + assert.equal(f.state.events.length, count) + f.page.onShow() + await settle() + assert.equal(f.state.events.filter(item => item === 'ready').length, 1) + f.state.scope = 'account-B' + f.page.onAudioTimeUpdate() + assert.equal(f.state.events.filter(item => item === 'audio-event').length, 1) + assert.equal(f.state.redirects.length, 1) +}) + +for (const [directory, pageId] of [['package-audio-c01-a', 'S01-C01-P01'], ['package-audio-c01-b', 'S01-C01-P05']]) { + test(`${directory}: real onReady waits for _page and unload clears both timers, context, and listeners`, async () => { + const f = fixture(`${directory}/pages/player/player.js`) + f.page.onLoad({ pageId }) + f.page.onShow() + f.page.onReady() + assert.equal(f.state.audio.length, 0) + f.finishBoot() + await settle() + assert.equal(f.page.data.audioReady, true) + assert.equal(f.state.audio.length, 1) + const context = f.state.audio[0] + f.page.onReady() + assert.equal(f.state.audio.length, 1) + f.page.requestSeek(10) + f.page.beginPauseLock(context) + assert.equal(f.state.timers.size, 2) + const staleTimers = [...f.state.timers.values()] + const staleAudio = Object.values(context.handlers) + f.page.onUnload() + assert.equal(context.destroys, 1) + assert.equal(f.state.timers.size, 0) + assert.equal(f.beginHandlers.size, 0) + assert.equal(f.endHandlers.size, 0) + assert.equal(f.page.audioContext, null) + const updates = f.state.updates + for (const callback of [...staleTimers, ...staleAudio]) callback() + assert.equal(context.plays, 0) + assert.equal(f.state.updates, updates) + f.page.onUnload() + assert.equal(context.destroys, 1) + }) + + test(`${directory}: hidden boot never initializes audio until return; changed-account hide retires old audio`, async () => { + const f = fixture(`${directory}/pages/player/player.js`) + f.page.onLoad({ pageId }) + f.page.onShow() + f.page.onReady() + f.page.onHide() + f.finishBoot() + await settle() + assert.equal(f.state.audio.length, 0) + f.page.onShow() + await settle() + assert.equal(f.page.data.audioReady, true) + const context = f.state.audio[0] + f.page.togglePlayback() + assert.equal(context.plays, 1) + f.state.scope = 'account-B' + f.page.onHide() + assert.ok(context.pauses >= 1) + assert.equal(context.destroys, 1) + assert.equal(f.state.timers.size, 0) + assert.equal(f.beginHandlers.size + f.endHandlers.size, 0) + const updates = f.state.updates + for (const callback of Object.values(context.handlers)) callback() + assert.equal(context.plays, 1) + assert.equal(f.state.updates, updates) + f.state.scope = 'account-A' + f.page.onShow() + await settle() + assert.equal(f.state.audio.length, 1) + assert.equal(f.state.redirects.length, 1) + f.page.onUnload() + assert.equal(context.destroys, 1) + }) +} + +test('real chapter unload destroys audio and stale callbacks cannot save or update', async () => { + const f = fixture('package-game/pages/chapter/chapter.js') + await open(f, { chapter: 1 }) + f.page.createAudioContext({ src: '/example.mp3', progressKey: 'page:S01-C01-P01', durationSeconds: 40 }) + const context = f.state.audio[0] + const staleAudio = Object.values(context.handlers) + f.page.onUnload() + assert.equal(context.destroys, 1) + assert.equal(f.page.audioContext, null) + assert.equal(f.state.saves.filter(item => item.kind === 'audio').length, 1) + const updates = f.state.updates + const saves = f.state.saves.length + for (const callback of staleAudio) callback() + assert.equal(f.state.updates, updates) + assert.equal(f.state.saves.length, saves) + assert.equal(context.plays, 0) +}) + +test('real chapter account-change hide cleans resources without writing previous audio progress into next account', async () => { + const f = fixture('package-game/pages/chapter/chapter.js') + await open(f, { chapter: 1 }) + f.page.createAudioContext({ src: '/example.mp3', progressKey: 'page:S01-C01-P01', durationSeconds: 40 }) + const context = f.state.audio[0] + f.state.scope = 'account-B' + f.page.onHide() + assert.equal(context.destroys, 1) + assert.equal(f.page.audioContext, null) + assert.equal(f.state.saves.filter(item => item.scope === 'account-B').length, 0) + context.handlers.Ended() + assert.equal(f.state.saves.filter(item => item.scope === 'account-B').length, 0) +}) + +test('unload before hydration prevents late onLoad/onShow/onReady and playback', async () => { + const f = fixture('package-audio-c01-a/pages/player/player.js') + f.page.onLoad({ pageId: 'S01-C01-P01' }) + f.page.onShow() + f.page.onReady() + f.page.onUnload() + f.finishBoot() + await settle() + assert.equal(f.state.audio.length, 0) + assert.equal(f.beginHandlers.size, 0) +}) + +test('catalog reset requires unchanged account and the same visible page lifetime', async () => { + for (const transition of ['none', 'account', 'hidden', 'returned', 'unloaded']) { + const f = fixture('pages/catalog/catalog.js') + await open(f) + f.page.restartStory() + assert.equal(f.state.modals.length, 1) + if (transition === 'account') f.state.scope = 'account-B' + if (transition === 'hidden' || transition === 'returned') f.page.onHide() + if (transition === 'returned') { f.page.onShow(); await settle() } + if (transition === 'unloaded') f.page.onUnload() + f.state.modals[0].success({ confirm: true }) + assert.deepEqual(f.state.resets, transition === 'none' ? ['account-A'] : [], transition) + assert.equal(f.state.redirects.length, transition === 'none' ? 1 : 0, transition) + } +}) + +test('cleanup permission ends synchronously even when the original unload throws', async () => { + const f = fixture(null, state => ({ + onUnload() { this.clearTimers(); throw new Error('cleanup failed') }, + clearTimers() { state.events.push('clear') }, + onAudioTimeUpdate() { state.events.push('late-event') }, + })) + await open(f) + assert.throws(() => f.page.onUnload(), /cleanup failed/) + assert.equal(f.page.__tangCleaning, false) + assert.deepEqual(f.state.events, ['clear', 'flush']) + f.page.clearTimers() + f.page.onAudioTimeUpdate() + assert.deepEqual(f.state.events, ['clear', 'flush']) +}) diff --git a/TongjiUniApp/scripts/test-tang-platform.cjs b/TongjiUniApp/scripts/test-tang-platform.cjs new file mode 100644 index 0000000..af512f7 --- /dev/null +++ b/TongjiUniApp/scripts/test-tang-platform.cjs @@ -0,0 +1,401 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const crypto = require('node:crypto') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const { createPlatformBridge } = require('../native-adapter/tang-detective/utils/platformCore') +const { emptyProgress, projectProgress } = require('../native-adapter/tang-detective/utils/progressContract') +const { sha256Hex } = require('../native-adapter/tang-detective/utils/identityHash') +const clone = value => JSON.parse(JSON.stringify(value)) +function fixture(t, initialToken = 'test-account-A') { + const storage = new Map([['token', initialToken]]) + const calls = [] + const servers = new Map() + const state = { offline: false, expired: false, failWrite: false, loseAck: false, beforeAck: null, saveError: '' } + function server(token) { + if (!servers.has(token)) servers.set(token, { user_id: token.endsWith('B') ? 2 : 1, + schema_version: 1, content_version: 'season-01', revision: 0, story_generation: 0, + progress: emptyProgress() }) + return servers.get(token) + } + const platform = { + getStorageSync: key => clone(storage.get(key) || ''), + setStorageSync(key, value) { if (state.failWrite) throw new Error('quota'); storage.set(key, clone(value)) }, + request(options) { + calls.push(clone({ url: options.url, method: options.method, header: options.header, data: options.data || null })) + queueMicrotask(() => { + if (state.offline) return options.fail({}) + if (state.expired) return options.success({ statusCode: 200, data: { code: -1 } }) + let remote = server(options.header.token) + const reply = (code, data) => options.success({ statusCode: 200, data: { code, data: clone(data) } }) + if (options.url.endsWith('/catalog')) return reply(1, { schema_version: 1, content_version: 'season-01' }) + if (options.url.endsWith('/progress')) return reply(1, remote) + assert.equal(options.method, 'POST') + if (state.saveError) return reply(0, { error_code: state.saveError }) + const body = options.data + assert.deepEqual(Object.keys(body).sort(), ['schema_version', 'content_version', 'base_revision', 'story_generation', 'request_id', 'operation', 'progress'].sort()) + assert.deepEqual(body.progress, projectProgress(body.progress)) + if (remote.lastRequest === body.request_id) return reply(1, remote) + if (remote.revision !== body.base_revision || remote.story_generation !== body.story_generation) return reply(0, { error_code: 'PROGRESS_CONFLICT' }) + remote.progress = clone(body.progress) + remote.revision++ + if (body.operation === 'reset_story') remote.story_generation++ + remote.lastRequest = body.request_id + if (state.beforeAck) { const callback = state.beforeAck; state.beforeAck = null; callback() } + if (state.loseAck) { state.loseAck = false; return options.fail({}) } + reply(1, remote) + }) + }, + } + const bridge = createPlatformBridge(platform, { apiBaseUrl: 'https://example.invalid/' }) + t.after(() => bridge.dispose()) + return { bridge, platform, storage, calls, state, server } +} +function advance(bridge, page = 'S01-C01-P02') { + return { ...bridge.getProgress(), lastChapter: 1, + comicReaderByChapter: { 'S01-C01': { currentPageId: page, completedEventIds: [], chapterFinished: false } }, + completedHotspots: { 'S01-C01': [] }, lastPageId: page } +} +test('SHA-256 uses the original portable implementation', () => { + for (const value of ['', 'abc', '测试-token']) assert.equal(sha256Hex(value), crypto.createHash('sha256').update(value).digest('hex')) +}) +test('wire projection excludes story text, health answers, credentials and invalid IDs', () => { + const projected = projectProgress({ ...emptyProgress(), healthAnswer: 'secret', token: 'secret', + memoryCardSnapshots: { secret: 'story text' }, lastChapter: 100, + collectedMemoryCards: ['S01-C01-MC01', 'illegal'], + comicReaderByChapter: { 'S01-C01': { currentPageId: 'S01-C01-P08', completedEventIds: ['S01-H02'], chapterFinished: true } } }) + assert.equal(projected.lastChapter, 1) + assert.equal(projected.comicReaderByChapter['S01-C01'].currentPageId, 'S01-C01-P03') + assert.equal(projected.comicReaderByChapter['S01-C01'].chapterFinished, false) + assert.deepEqual(projected.collectedMemoryCards, ['S01-C01-MC01']) + assert.ok(!JSON.stringify(projected).includes('secret')) +}) +test('guest can read locally without any API calls, and guest state is not uploaded after login', async t => { + const f = fixture(t, '') + await f.bridge.open() + assert.equal(f.bridge.saveProgress(advance(f.bridge)), true) + await f.bridge.flush() + assert.equal(f.calls.length, 0) + f.storage.set('token', 'test-account-A') + await f.bridge.open() + assert.equal(f.bridge.getProgress().lastPageId, '') + assert.equal(f.calls.filter(c => c.method === 'POST').length, 0) +}) +test('authenticated save uses existing token header, JSON, whitelist and own revision', async t => { + const f = fixture(t) + await f.bridge.open() + f.bridge.saveProgress({ ...advance(f.bridge), memoryCardSnapshots: { a: 'stay local' }, answer: 'stay local' }) + await f.bridge.flush() + const post = f.calls.find(c => c.method === 'POST') + assert.equal(post.header.token, 'test-account-A') + assert.equal(post.header['content-type'], 'application/json') + assert.equal(post.data.base_revision, 0) + assert.equal(post.data.progress.lastPageId, 'S01-C01-P02') + assert.ok(!JSON.stringify(post.data).includes('stay local')) + assert.equal(f.bridge.getStatus(), 'synced') + assert.equal(f.bridge.getProgress().answer, 'stay local') + for (const [key, value] of f.storage) if (key !== 'token') assert.ok(!JSON.stringify([key, value]).includes('test-account-A')) +}) +test('offline edits survive and retry when API becomes available', async t => { + const f = fixture(t) + f.state.offline = true + await f.bridge.open() + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(f.calls.filter(c => c.method === 'POST').length, 0) + f.state.offline = false + await f.bridge.open(true) + assert.equal(f.bridge.getStatus(), 'synced') + assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P02') +}) +test('login expiration never silently claims synchronization', async t => { + const f = fixture(t) + f.state.expired = true + await f.bridge.open() + assert.equal(f.bridge.getStatus(), 'auth-expired') + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(f.calls.filter(c => c.method === 'POST').length, 0) +}) +test('account changes isolate progress and reject delayed writes from the previous page', async t => { + const f = fixture(t) + await f.bridge.open() + const old = advance(f.bridge) + f.bridge.saveProgress(old) + await f.bridge.flush() + f.storage.set('token', 'test-account-B') + await f.bridge.open() + assert.equal(f.bridge.getProgress().lastPageId, '') + assert.equal(f.bridge.saveProgress(old), false) + assert.equal(f.server('test-account-B').revision, 0) +}) +test('late HTTP response after account switch does not hydrate the new account', async t => { + const f = fixture(t) + const first = f.bridge.open() + f.storage.set('token', 'test-account-B') + const second = f.bridge.open() + await Promise.all([first, second]) + assert.equal(f.bridge.getProgress().lastPageId, '') + assert.equal(f.bridge.getStatus(), 'synced') + assert.ok(f.bridge.getScope().endsWith(sha256Hex('test-account-B'))) +}) +test('conflict keeps both versions; explicit cloud choice takes a recoverable backup', async t => { + const f = fixture(t) + await f.bridge.open() + f.server('test-account-A').revision = 4 + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(f.bridge.getStatus(), 'conflict') + assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02') + assert.equal(await f.bridge.resolveConflict('cloud', f.bridge.getConflictContext()), true) + assert.equal(f.bridge.getProgress().lastPageId, '') + assert.ok([...f.storage.keys()].some(key => key.endsWith(':conflict-backup'))) +}) +test('explicit local conflict resolution uses the new server revision', async t => { + const f = fixture(t) + await f.bridge.open() + f.server('test-account-A').revision = 2 + f.server('test-account-A').story_generation = 1 + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(await f.bridge.resolveConflict('local', f.bridge.getConflictContext()), true) + const last = f.calls.filter(c => c.method === 'POST').at(-1) + assert.equal(last.data.base_revision, 2) + assert.equal(last.data.story_generation, 1) +}) +test('lost response retries the same request ID and does not double increment', async t => { + const f = fixture(t) + await f.bridge.open() + f.state.loseAck = true + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(f.bridge.getStatus(), 'offline') + await f.bridge.open(true) + const posts = f.calls.filter(c => c.method === 'POST') + assert.equal(posts.length, 2) + assert.equal(posts[0].data.request_id, posts[1].data.request_id) + assert.equal(f.server('test-account-A').revision, 1) + assert.equal(f.bridge.getStatus(), 'synced') +}) +test('new edits made while saving are queued without being overwritten by an old response', async t => { + const f = fixture(t) + await f.bridge.open() + f.state.beforeAck = () => f.bridge.saveProgress(advance(f.bridge, 'S01-C01-P03')) + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P03') + await f.bridge.flush() + assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P03') +}) +test('reset uses an empty story and preserves valid unsynced card IDs', async t => { + const f = fixture(t) + await f.bridge.open() + f.bridge.saveProgress({ ...f.bridge.getProgress(), ...emptyProgress(), collectedMemoryCards: ['S01-C01-MC01'] }, true) + await f.bridge.flush() + const post = f.calls.find(c => c.method === 'POST') + assert.equal(post.data.operation, 'reset_story') + assert.deepEqual(post.data.progress.collectedMemoryCards, ['S01-C01-MC01']) + assert.deepEqual(post.data.progress.comicReaderByChapter, {}) + assert.equal(f.server('test-account-A').story_generation, 1) +}) +test('reset requested during an in-flight replace is not dropped', async t => { + const f = fixture(t) + await f.bridge.open() + f.state.beforeAck = () => f.bridge.saveProgress({ ...f.bridge.getProgress(), ...emptyProgress() }, true) + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + await f.bridge.flush() + assert.equal(f.calls.filter(c => c.method === 'POST').at(-1).data.operation, 'reset_story') +}) +test('storage errors stop cloud writes and are reported truthfully', async t => { + const f = fixture(t) + await f.bridge.open() + f.state.failWrite = true + assert.equal(f.bridge.saveProgress(advance(f.bridge)), false) + await f.bridge.flush() + assert.equal(f.bridge.getStatus(), 'storage-error') + assert.equal(f.calls.filter(c => c.method === 'POST').length, 0) +}) +test('settings/audio remain local and scoped', async t => { + const f = fixture(t) + await f.bridge.open() + f.bridge.writeLocal('settings', { fontScale: 'xlarge' }) + f.bridge.writeLocal('audio', { page: 200 }) + assert.equal(f.calls.filter(c => c.method === 'POST').length, 0) + f.storage.set('token', 'test-account-B') + await f.bridge.open() + assert.deepEqual(f.bridge.readLocal('audio', {}), {}) +}) + +test('a renewed token recovers the authenticated user local unsynced queue', async t => { + const f = fixture(t) + await f.bridge.open() + f.state.offline = true + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + f.storage.set('token', 'renewed-test-account-A') + f.state.offline = false + await f.bridge.open() + assert.equal(f.bridge.getStatus(), 'synced') + assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02') + assert.equal(f.server('renewed-test-account-A').revision, 1) +}) + +test('cloud hydration does not claim local persistence when storage is full', async t => { + const f = fixture(t) + f.state.failWrite = true + await f.bridge.open() + assert.equal(f.bridge.getStatus(), 'storage-error') +}) + +test('permanent contract failure is not automatically resubmitted by page hide or new edits', async t => { + const f = fixture(t) + await f.bridge.open() + f.state.saveError = 'INVALID_REQUEST' + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(f.bridge.getStatus(), 'sync-error') + f.bridge.saveProgress(advance(f.bridge, 'S01-C01-P03')) + await f.bridge.flush() + await f.bridge.open() + assert.equal(f.calls.filter(c => c.method === 'POST').length, 1) +}) + +test('review regression: offline reset then new reading sends reset followed by the new progress', async t => { + const f = fixture(t) + f.state.offline = true + await f.bridge.open() + f.bridge.saveProgress({ ...f.bridge.getProgress(), ...emptyProgress() }, true) + f.bridge.saveProgress(advance(f.bridge)) + f.state.offline = false + await f.bridge.open(true) + assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02') + await f.bridge.flush() + const posts = f.calls.filter(c => c.method === 'POST') + assert.deepEqual(posts.map(c => c.data.operation), ['reset_story', 'replace']) + assert.equal(posts[1].data.story_generation, 1) + assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P02') + assert.equal(f.bridge.getStatus(), 'synced') +}) + +test('review regression: queue persistence failure releases in-flight lock and can recover', async t => { + const f = fixture(t) + await f.bridge.open() + assert.equal(f.bridge.saveProgress(advance(f.bridge)), true) + f.state.failWrite = true + await f.bridge.flush() + assert.equal(f.bridge.getStatus(), 'storage-error') + assert.equal(f.calls.filter(c => c.method === 'POST').length, 0) + f.state.failWrite = false + await f.bridge.open(true) + await f.bridge.flush() + assert.equal(f.bridge.getStatus(), 'synced') + assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P02') +}) + +test('review regression: conflict confirmation is bound to account and exact local/conflict revision', async t => { + const f = fixture(t) + await f.bridge.open() + f.server('test-account-A').revision = 2 + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + const oldContext = f.bridge.getConflictContext() + f.bridge.saveProgress(advance(f.bridge, 'S01-C01-P03')) + assert.equal(await f.bridge.resolveConflict('cloud', oldContext), false) + assert.equal(await f.bridge.resolveConflict('cloud'), false) + f.storage.set('token', 'test-account-B') + await f.bridge.open() + f.server('test-account-B').revision = 2 + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(f.bridge.getStatus(), 'conflict') + assert.equal(await f.bridge.resolveConflict('cloud', oldContext), false) + assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02') +}) + +test('review regression: reset helper rejects missing or stale confirmation scope', async t => { + const f = fixture(t) + await f.bridge.open() + const oldScope = f.bridge.getScope() + const sandbox = { module: { exports: {} }, require: name => name === './platformBridge' ? f.bridge : { emptyProgress } } + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../native-adapter/tang-detective/utils/storage.js'), 'utf8'), sandbox) + const reset = sandbox.module.exports.resetStoryProgress + f.storage.set('token', 'test-account-B') + await f.bridge.open() + f.bridge.saveProgress(advance(f.bridge)) + await f.bridge.flush() + assert.equal(reset(), false) + assert.equal(reset(oldScope), false) + assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02') + assert.equal(f.server('test-account-B').story_generation, 0) + assert.equal(reset(f.bridge.getScope()), true) + await f.bridge.flush() + assert.equal(f.server('test-account-B').story_generation, 1) +}) + +function pageFixture() { + let finishBoot + let scope = 'account-A' + let page + const events = [] + const boot = new Promise(resolve => { finishBoot = resolve }) + const bridge = { getScope: () => scope, open: () => boot, flush: () => events.push('flush') } + const sandbox = { module: { exports: {} }, require: () => bridge, + Page: options => { page = { ...options, data: { ...options.data }, setData(data) { Object.assign(this.data, data) } } }, + wx: { reLaunch: () => events.push('reLaunch'), showToast: () => events.push('error') } } + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../native-adapter/tang-detective/utils/tangPage.js'), 'utf8'), sandbox) + sandbox.module.exports({ data: { value: 1 }, onLoad() { events.push('load') }, onShow() { events.push('show') }, + onHide() { events.push('hide') }, onUnload() { events.push('unload') }, click() { events.push('click') } }) + return { page, events, finishBoot, changeAccount: () => { scope = 'account-B' } } +} +const settlePage = () => new Promise(resolve => setImmediate(resolve)) +test('native lifecycle waits for account hydration and blocks pre-boot interaction', async () => { + const f = pageFixture() + f.page.onLoad({}) + f.page.onShow() + f.page.click() + assert.deepEqual(f.events, []) + assert.equal(f.page.data.tangBootPending, true) + f.finishBoot() + await settlePage() + assert.deepEqual(f.events, ['load', 'show']) + assert.equal(f.page.data.tangBootPending, false) + f.page.click() + assert.equal(f.events.at(-1), 'click') +}) +test('native page hidden during boot initializes only on return, then cleans up', async () => { + const f = pageFixture() + f.page.onLoad({}) + f.page.onShow() + f.page.onHide() + f.finishBoot() + await settlePage() + assert.deepEqual(f.events, ['flush']) + f.page.onShow() + await settlePage() + assert.deepEqual(f.events, ['flush', 'load', 'show']) + f.page.onUnload() + f.page.click() + assert.deepEqual(f.events.slice(-2), ['unload', 'flush']) +}) +test('native page unloaded before HTTP completion cannot start late playback/initialization', async () => { + const f = pageFixture() + f.page.onLoad({}) + f.page.onShow() + f.page.onUnload() + f.finishBoot() + await settlePage() + assert.deepEqual(f.events, ['flush']) +}) +test('native event after host account changes returns home before old game mutation', async () => { + const f = pageFixture() + f.page.onLoad({}) + f.page.onShow() + f.finishBoot() + await settlePage() + f.changeAccount() + f.page.click() + assert.equal(f.events.at(-1), 'reLaunch') + assert.ok(!f.events.includes('click')) +}) diff --git a/TongjiUniApp/tongji/tang-detective/index.vue b/TongjiUniApp/tongji/tang-detective/index.vue new file mode 100644 index 0000000..140d7b4 --- /dev/null +++ b/TongjiUniApp/tongji/tang-detective/index.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/server/app/api/controller/TangDetectiveController.php b/server/app/api/controller/TangDetectiveController.php new file mode 100644 index 0000000..5ce7a1d --- /dev/null +++ b/server/app/api/controller/TangDetectiveController.php @@ -0,0 +1,68 @@ +respond('GET', static fn (): array => (new TangDetectiveProgress())->catalog()); + } + + public function progress() + { + return $this->respond('GET', fn (): array => (new TangDetectiveLogic())->read((int) $this->userId)); + } + + public function saveProgress() + { + return $this->respond('POST', function (): array { + if (strtolower($this->request->contentType()) !== 'application/json') { + throw new TangDetectiveProgressException('UNSUPPORTED_MEDIA_TYPE', '请使用 JSON 提交存档'); + } + $policy = new TangDetectiveProgress(); + $request = $policy->decodeRequest((string) $this->request->getContent()); + return (new TangDetectiveLogic($policy))->save((int) $this->userId, $request); + }); + } + + private function respond(string $method, callable $action) + { + try { + // Guard methods too: ThinkPHP's conventional controller routes remain enabled. + if (($method === 'GET' && !$this->request->isGet()) + || ($method === 'POST' && !$this->request->isPost())) { + throw new TangDetectiveProgressException('METHOD_NOT_ALLOWED', '请求方式不正确'); + } + if ($this->userId <= 0) { + throw new TangDetectiveProgressException('AUTH_REQUIRED', '请先登录'); + } + return $this->data($action())->header(['Cache-Control' => 'no-store']); + } catch (TangDetectiveProgressException $exception) { + return $this->fail($exception->getMessage(), ['error_code' => $exception->errorCode()], 0, 0) + ->header(['Cache-Control' => 'no-store']); + } catch (\Throwable $exception) { + try { + Log::warning('tang_detective_request_failure', [ + 'user_id' => $this->userId, + 'error_code' => 'STORAGE_UNAVAILABLE', + 'exception_class' => get_class($exception), + ]); + } catch (\Throwable $loggingException) { + // No raw exception, request body, token or SQL is exposed on failure. + } + return $this->fail('云端存档暂时不可用,本机进度仍可保留', ['error_code' => 'STORAGE_UNAVAILABLE'], 0, 0) + ->header(['Cache-Control' => 'no-store']); + } + } +} diff --git a/server/app/api/logic/tcm/TangDetectiveLogic.php b/server/app/api/logic/tcm/TangDetectiveLogic.php new file mode 100644 index 0000000..5f68472 --- /dev/null +++ b/server/app/api/logic/tcm/TangDetectiveLogic.php @@ -0,0 +1,154 @@ +policy = $policy ?? new TangDetectiveProgress(); + } + + public function read(int $userId): array + { + $this->policy->defaultState($userId); + try { + $row = Db::name(self::TABLE)->where('user_id', $userId)->find(); + return $row ? $this->hydrate($userId, $row) : $this->policy->defaultState($userId); + } catch (\Throwable $exception) { + $this->storageFailure('read', $userId, $exception); + } + } + + /** $request is exclusively the output of TangDetectiveProgress::decodeRequest. */ + public function save(int $userId, array $request): array + { + $this->policy->defaultState($userId); + $transactionOpen = false; + try { + Db::startTrans(); + $transactionOpen = true; + $row = Db::name(self::TABLE)->where('user_id', $userId)->lock(true)->find(); + $current = $row ? $this->hydrate($userId, $row) : $this->policy->defaultState($userId); + $change = $this->policy->transition( + $current, + $request, + (string) ($row['last_request_id'] ?? ''), + (string) ($row['last_request_hash'] ?? '') + ); + if (!$change['idempotent']) { + $state = $change['state']; + $values = [ + 'schema_version' => $state['schema_version'], + 'content_version' => $state['content_version'], + 'revision' => $state['revision'], + 'story_generation' => $state['story_generation'], + 'progress_json' => $this->policy->encode($state['progress']), + 'last_request_id' => $request['request_id'], + 'last_request_hash' => $change['request_hash'], + 'update_time' => time(), + ]; + if ($row) { + $updated = Db::name(self::TABLE) + ->where('user_id', $userId) + ->where('revision', $current['revision']) + ->where('story_generation', $current['story_generation']) + ->update($values); + if ($updated !== 1) { + throw new TangDetectiveProgressException('PROGRESS_CONFLICT', '存档已变化,请重新读取'); + } + } else { + // UNIQUE(user_id) arbitrates concurrent first saves. Never upsert/overwrite. + $inserted = Db::name(self::TABLE)->insert($values + [ + 'user_id' => $userId, + 'create_time' => time(), + ]); + if ((int) $inserted !== 1) { + throw new \UnexpectedValueException('Tang progress insert was not confirmed'); + } + } + } + Db::commit(); + $transactionOpen = false; + return $change['state'] + ['idempotent' => $change['idempotent']]; + } catch (\Throwable $exception) { + if ($transactionOpen) { + try { + Db::rollback(); + } catch (\Throwable $rollbackException) { + $this->storageFailure('rollback', $userId, $rollbackException); + } + } + if ($exception instanceof TangDetectiveProgressException) { + throw $exception; + } + if ($this->isConcurrentWriteFailure($exception)) { + throw new TangDetectiveProgressException('PROGRESS_CONFLICT', '另一处正在保存,请重新读取存档'); + } + $this->storageFailure('save', $userId, $exception); + } + } + + private function hydrate(int $userId, array $row): array + { + if ((int) $row['user_id'] !== $userId || (int) $row['schema_version'] !== 1 + || $row['content_version'] !== 'season-01' + || (int) $row['revision'] < 1 || (int) $row['story_generation'] < 0) { + throw new \UnexpectedValueException('Stored Tang progress metadata is invalid'); + } + try { + $progress = $this->policy->normalizeProgress( + json_decode((string) $row['progress_json'], false, 12, JSON_THROW_ON_ERROR) + ); + } catch (\Throwable $exception) { + // Corrupt persisted data is not a client validation error. Do not echo its content. + throw new \UnexpectedValueException('Stored Tang progress is invalid'); + } + $state = $this->policy->defaultState($userId); + $state['revision'] = (int) $row['revision']; + $state['story_generation'] = (int) $row['story_generation']; + $state['progress'] = $progress; + return $state; + } + + private function isConcurrentWriteFailure(\Throwable $exception): bool + { + if ($exception instanceof \PDOException) { + return in_array((int) ($exception->errorInfo[1] ?? 0), [1062, 1205, 1213], true) + || (string) $exception->getCode() === '40001'; + } + if ($exception instanceof \think\db\exception\PDOException) { + $details = $exception->getData()['PDO Error Info'] ?? []; + return in_array((int) ($details['Driver Error Code'] ?? 0), [1062, 1205, 1213], true) + || ($details['SQLSTATE'] ?? '') === '40001'; + } + return false; + } + + private function storageFailure(string $operation, int $userId, \Throwable $exception): void + { + try { + // Deliberately omit exception message/trace: ORM errors may contain SQL or payloads. + Log::warning('tang_detective_storage_failure', [ + 'operation' => $operation, + 'user_id' => $userId, + 'error_code' => 'STORAGE_UNAVAILABLE', + 'exception_class' => get_class($exception), + ]); + } catch (\Throwable $loggingException) { + // An unavailable log sink must not reveal the original storage exception. + } + throw new TangDetectiveProgressException('STORAGE_UNAVAILABLE', '云端存档暂时不可用,本机进度仍可保留'); + } +} diff --git a/server/app/common/service/game/TangDetectiveProgress.php b/server/app/common/service/game/TangDetectiveProgress.php new file mode 100644 index 0000000..f97dd7c --- /dev/null +++ b/server/app/common/service/game/TangDetectiveProgress.php @@ -0,0 +1,251 @@ +catalog = $catalog ?? require dirname(__DIR__, 4) . '/config/tang_detective.php'; + foreach ($this->catalog['chapters'] as $chapter) { + $this->chapters[$chapter['chapter_id']] = $chapter; + $this->cardIds[] = $chapter['memory_card_id']; + } + } + + public function catalog(): array + { + return $this->catalog; + } + + public function defaultProgress(): array + { + return [ + 'completedHotspots' => (object) [], + 'completedChapters' => [], + 'lastChapter' => 1, + 'collectedMemoryCards' => [], + 'comicReaderByChapter' => (object) [], + 'lastPageId' => '', + ]; + } + + public function defaultState(int $userId): array + { + if ($userId <= 0) { + throw new TangDetectiveProgressException('AUTH_REQUIRED', '请先登录'); + } + return [ + 'user_id' => $userId, + 'schema_version' => 1, + 'content_version' => 'season-01', + 'revision' => 0, + 'story_generation' => 0, + 'progress' => $this->defaultProgress(), + ]; + } + + /** Decode raw JSON so objects, lists, integers and booleans stay distinct. */ + public function decodeRequest(string $raw): array + { + if (strlen($raw) > self::MAX_BODY_BYTES) { + throw new TangDetectiveProgressException('PAYLOAD_TOO_LARGE', '存档请求超过大小限制'); + } + try { + $decoded = json_decode($raw, false, 12, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + $this->invalid('存档请求不是有效的 JSON 对象'); + } + $source = $this->record($decoded, [ + 'schema_version', 'content_version', 'base_revision', 'story_generation', + 'request_id', 'operation', 'progress', + ], true); + if ($source['schema_version'] !== 1 || $source['content_version'] !== 'season-01') { + throw new TangDetectiveProgressException('UNSUPPORTED_CONTENT_VERSION', '存档版本不匹配,请更新后再试'); + } + $this->boundedInteger($source['base_revision'], 0, 2147483646); + $this->boundedInteger($source['story_generation'], 0, 2147483646); + if (!is_string($source['request_id']) || !preg_match('/\A[A-Za-z0-9_-]{16,64}\z/D', $source['request_id'])) { + $this->invalid('存档请求标识无效'); + } + if (!in_array($source['operation'], ['replace', 'reset_story'], true)) { + $this->invalid('存档操作无效'); + } + $progress = $this->normalizeProgress($source['progress']); + if ($source['operation'] === 'reset_story') { + $empty = $this->defaultProgress(); + $empty['collectedMemoryCards'] = $progress['collectedMemoryCards']; + if ($this->encode($empty) !== $this->encode($progress)) { + $this->invalid('重新开始请求必须清空故事进度'); + } + } + return [ + 'schema_version' => 1, + 'content_version' => 'season-01', + 'base_revision' => $source['base_revision'], + 'story_generation' => $source['story_generation'], + 'request_id' => $source['request_id'], + 'operation' => $source['operation'], + 'progress' => $progress, + ]; + } + + /** Reject unknown fields; canonicalize only equivalent ordering of sets/maps. */ + public function normalizeProgress($value): array + { + $source = $this->record($value, [ + 'completedHotspots', 'completedChapters', 'lastChapter', + 'collectedMemoryCards', 'comicReaderByChapter', 'lastPageId', + ], true); + $this->boundedInteger($source['lastChapter'], 1, count($this->chapters)); + $hotspots = $this->record($source['completedHotspots'], array_keys($this->chapters)); + $readers = $this->record($source['comicReaderByChapter'], array_keys($this->chapters)); + $finished = $this->idSet($source['completedChapters'], array_keys($this->chapters)); + $cards = $this->idSet($source['collectedMemoryCards'], $this->cardIds); + $cleanHotspots = []; + $cleanReaders = []; + foreach ($this->chapters as $id => $chapter) { + $events = $this->eventPrefix($hotspots[$id] ?? [], $chapter['hotspot_ids']); + if (array_key_exists($id, $hotspots)) { + $cleanHotspots[$id] = $events; + } + $isFinished = in_array($id, $finished, true); + if (!array_key_exists($id, $readers)) { + if ($events !== [] || $isFinished) { + $this->invalid('章节存档缺少一致的阅读状态'); + } + continue; + } + $reader = $this->record($readers[$id], ['currentPageId', 'completedEventIds', 'chapterFinished'], true); + $readerEvents = $this->eventPrefix($reader['completedEventIds'], $chapter['hotspot_ids']); + if (!is_bool($reader['chapterFinished']) || $readerEvents !== $events + || $reader['chapterFinished'] !== $isFinished + || ($isFinished && count($events) !== 4)) { + $this->invalid('章节完成状态与事件进度不一致'); + } + $pageIndex = is_string($reader['currentPageId']) + ? array_search($reader['currentPageId'], $chapter['page_ids'], true) + : false; + $lastUnlocked = $isFinished ? 7 : min(6, 2 + count($events)); + if ($pageIndex === false || $pageIndex > $lastUnlocked) { + $this->invalid('阅读页码无效或尚未解锁'); + } + $cleanReaders[$id] = [ + 'currentPageId' => $reader['currentPageId'], + 'completedEventIds' => $readerEvents, + 'chapterFinished' => $reader['chapterFinished'], + ]; + } + if (!is_string($source['lastPageId'])) { + $this->invalid('最后阅读页码无效'); + } + $lastId = sprintf('S01-C%02d', $source['lastChapter']); + if ($source['lastPageId'] !== '' && ( + !isset($cleanReaders[$lastId]) + || $cleanReaders[$lastId]['currentPageId'] !== $source['lastPageId'] + )) { + $this->invalid('最后阅读页码与章节书签不一致'); + } + return [ + 'completedHotspots' => (object) $cleanHotspots, + 'completedChapters' => $finished, + 'lastChapter' => $source['lastChapter'], + 'collectedMemoryCards' => $cards, + 'comicReaderByChapter' => (object) $cleanReaders, + 'lastPageId' => $source['lastPageId'], + ]; + } + + /** Called under the user's database row lock; request must be decodeRequest's result. */ + public function transition(array $current, array $request, string $lastRequestId = '', string $lastHash = ''): array + { + $hash = hash('sha256', $this->encode($request)); + if ($lastRequestId !== '' && hash_equals($lastRequestId, $request['request_id'])) { + if (!hash_equals($lastHash, $hash)) { + throw new TangDetectiveProgressException('IDEMPOTENCY_CONFLICT', '同一请求标识不能提交不同存档'); + } + return ['state' => $current, 'request_hash' => $hash, 'idempotent' => true]; + } + if ($request['base_revision'] !== $current['revision'] + || $request['story_generation'] !== $current['story_generation']) { + throw new TangDetectiveProgressException('PROGRESS_CONFLICT', '存档已变化,请选择云端或本机进度'); + } + if ($current['revision'] >= 2147483646 || $current['story_generation'] >= 2147483646) { + throw new TangDetectiveProgressException('PROGRESS_CONFLICT', '存档版本超出范围,请联系管理员'); + } + $next = $current; + $next['progress'] = $request['progress']; + if ($request['operation'] === 'reset_story') { + $next['progress'] = $this->defaultProgress(); + $cards = array_unique(array_merge( + $current['progress']['collectedMemoryCards'], + $request['progress']['collectedMemoryCards'] + )); + $next['progress']['collectedMemoryCards'] = array_values(array_intersect($this->cardIds, $cards)); + ++$next['story_generation']; + } + ++$next['revision']; + return ['state' => $next, 'request_hash' => $hash, 'idempotent' => false]; + } + + public function encode(array $value): string + { + return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } + + private function record($value, array $allowed, bool $requireAll = false): array + { + if (!$value instanceof \stdClass) { + $this->invalid('存档字段必须是对象'); + } + $fields = get_object_vars($value); + if (array_diff(array_keys($fields), $allowed) !== [] + || ($requireAll && array_diff($allowed, array_keys($fields)) !== [])) { + $this->invalid('存档包含未知字段或缺少必要字段'); + } + return $fields; + } + + private function idSet($value, array $allowed): array + { + if (!is_array($value) || $value !== array_values($value) || count($value) > count($allowed)) { + $this->invalid('存档标识列表无效'); + } + $seen = []; + foreach ($value as $id) { + if (!is_string($id) || !in_array($id, $allowed, true) || isset($seen[$id])) { + $this->invalid('存档包含非法或重复标识'); + } + $seen[$id] = true; + } + return array_values(array_filter($allowed, static fn (string $id): bool => isset($seen[$id]))); + } + + private function eventPrefix($value, array $allowed): array + { + if (!is_array($value) || $value !== array_slice($allowed, 0, count($value))) { + $this->invalid('事件进度必须是本章连续前缀'); + } + return $value; + } + + private function boundedInteger($value, int $minimum, int $maximum): void + { + if (!is_int($value) || $value < $minimum || $value > $maximum) { + $this->invalid('存档整数参数无效'); + } + } + + private function invalid(string $message): void + { + throw new TangDetectiveProgressException('INVALID_REQUEST', $message); + } +} diff --git a/server/app/common/service/game/TangDetectiveProgressException.php b/server/app/common/service/game/TangDetectiveProgressException.php new file mode 100644 index 0000000..5a78a85 --- /dev/null +++ b/server/app/common/service/game/TangDetectiveProgressException.php @@ -0,0 +1,21 @@ +errorCode = $errorCode; + } + + public function errorCode(): string + { + return $this->errorCode; + } +} diff --git a/server/config/tang_detective.php b/server/config/tang_detective.php new file mode 100644 index 0000000..87e9c9b --- /dev/null +++ b/server/config/tang_detective.php @@ -0,0 +1,32 @@ + $chapterId, + 'chapter_number' => $number, + 'hotspot_ids' => array_map( + static fn (int $event): string => sprintf('S01-H%02d', $event), + range(($number - 1) * 4 + 1, $number * 4) + ), + 'page_ids' => array_map( + static fn (int $page): string => sprintf('%s-P%02d', $chapterId, $page), + range(1, 8) + ), + 'memory_card_id' => $chapterId . '-MC01', + ]; +} + +return [ + 'game_id' => 'tang-detective', + 'schema_version' => 1, + 'content_version' => 'season-01', + 'max_body_bytes' => 32768, + 'chapters' => $chapters, +]; diff --git a/server/docs/tang-detective-progress.md b/server/docs/tang-detective-progress.md new file mode 100644 index 0000000..4435950 --- /dev/null +++ b/server/docs/tang-detective-progress.md @@ -0,0 +1,135 @@ +# 唐侦探独立存档契约与验证边界 + +本模块只保存 `season-01` 的章节、事件、卡片 ID 和阅读游标。它复用主小程序登录身份,不读取或写入三消周榜、患者记录或健康回答。机器可读契约见 `tang-detective.openapi.yaml`。 + +## 前端请求与响应 + +- `GET /api/tang/catalog`:部署目录的 ID 白名单,不创建用户存档。 +- `GET /api/tang/progress`:当前登录用户的确认存档;未保存过返回修订与代次均为 `0`。 +- `POST /api/tang/saveProgress`:仅接受 `application/json`,原始正文最多 `32768` 字节。 +- 三个端点均要求现有请求头 `token`。正文不接受 `user_id`、token 或任何身份替代字段。 +- 业务响应沿用 `{code, show, msg, data}`,HTTP 200 本身不代表成功。控制器响应设置 `Cache-Control: no-store`。 + +目录 `data` 为: + +```json +{ + "game_id": "tang-detective", + "schema_version": 1, + "content_version": "season-01", + "max_body_bytes": 32768, + "chapters": [{ + "chapter_id": "S01-C01", + "chapter_number": 1, + "hotspot_ids": ["S01-H01", "S01-H02", "S01-H03", "S01-H04"], + "page_ids": ["S01-C01-P01", "S01-C01-P02", "S01-C01-P03", "S01-C01-P04", "S01-C01-P05", "S01-C01-P06", "S01-C01-P07", "S01-C01-P08"], + "memory_card_id": "S01-C01-MC01" + }] +} +``` + +示例仅列第一章;实际返回固定十五章。ID 依据原项目 `miniprogram/data/season.js`、`data/memoryCards.js`、`package-game/pages/chapter/chapterPages.js` 与 `package-game/utils/comicReaderState.js` 核对。`season-01` 是本同步契约版本,不代表内容已医学审签或公开发布。 + +空存档的 `data`: + +```json +{ + "user_id": 11, + "schema_version": 1, + "content_version": "season-01", + "revision": 0, + "story_generation": 0, + "progress": { + "completedHotspots": {}, + "completedChapters": [], + "lastChapter": 1, + "collectedMemoryCards": [], + "comicReaderByChapter": {}, + "lastPageId": "" + } +} +``` + +`user_id` 仅为当前用户 ID,供前端隔离本机存档命名空间;它由登录中间件提供,不能由客户端选择。保存成功返回同一结构,并额外提供 `idempotent` 布尔值。 + +请求示例: + +```json +{ + "schema_version": 1, + "content_version": "season-01", + "base_revision": 0, + "story_generation": 0, + "request_id": "tang_request_00000001", + "operation": "replace", + "progress": { + "completedHotspots": {"S01-C01": ["S01-H01"]}, + "completedChapters": [], + "lastChapter": 1, + "collectedMemoryCards": [], + "comicReaderByChapter": { + "S01-C01": { + "currentPageId": "S01-C01-P04", + "completedEventIds": ["S01-H01"], + "chapterFinished": false + } + }, + "lastPageId": "S01-C01-P04" + } +} +``` + +必须提供全部七个请求字段及六个进度字段。映射使用 `{}`;数组、字符串整数、数字布尔值、未知字段、重复/外章 ID、事件缺口、乱序一律拒绝。事件只能是本章固定四事件的连续前缀。有事件或已完成章节必须存在对应阅读对象;事件镜像相同,`chapterFinished` 与 `completedChapters` 成员关系一致。四事件完成只解锁 P07,不自动完成章节;P08 要求明确 `chapterFinished=true`。 + +`lastPageId` 可为空;非空时须等于 `lastChapter` 对应 `currentPageId`。收藏只验证卡 ID,可在重玩清空故事后继续保留;不要求当前轮故事已完成该章。 + +禁止直接序列化原版 `getProgress()`。原版会保留额外字段,且 `memoryCardSnapshots` 包含正文。本机可保留这些字段,提交前必须按本契约另建投影;不可发送健康选择、答案、文字快照、音频内容、偏好设置、姓名、手机或用户画像。 + +## 替换、冲突与重置 + +`replace` 仅在 `base_revision` 与 `story_generation` 都匹配时替换整份投影,修订加一。用户在冲突界面明确选择保留本机时,可以在重新读取云端版本后发送新请求 ID;禁止后台擅自把旧投影套到新修订上。 + +`reset_story` 请求的故事字段必须为空、`lastChapter=1`、`lastPageId=''`,可以保留已验证收藏 ID。在版本匹配后,服务器保留“当前服务器收藏 ∪ 本次请求收藏”,清空其他故事字段,修订和故事代次各加一。前端本机重置待同步时,暂停普通保存与自动冲突重放;老代次队列不能重新进入新故事。离线新获得的卡 ID 不会因成功重置而丢失。 + +最后一次成功请求的 ID 和规范化内容摘要存储在同一行。相同 ID、相同内容重试只返回原确认状态;同 ID 不同内容返回 `IDEMPOTENCY_CONFLICT`。后续新提交成功后,更早的重试不在缓存窗口内,版本不匹配时返回 `PROGRESS_CONFLICT`,不会再次执行。重置超时只能重试原请求或读取后协调,不能自动换 ID 再重置。 + +失败 `data` 为 `{"error_code":"..."}`: + +| error_code | 前端处理 | +|---|---| +| `PROGRESS_CONFLICT` | 暂停队列、读取云端,提示用户选择云端或本机;不覆盖、不自动合并 | +| `IDEMPOTENCY_CONFLICT` | 暂停错误请求;同一请求 ID 不可变更内容 | +| `INVALID_REQUEST` | 投影或关联约束不符合契约;保留本机,停止原样重试 | +| `PAYLOAD_TOO_LARGE` | 正文超过 32 KiB;不可分片绕过,应修复白名单投影 | +| `UNSUPPORTED_CONTENT_VERSION` | 不兼容版本;保留本机,更新后协调 | +| `UNSUPPORTED_MEDIA_TYPE` | 改为 `application/json` | +| `METHOD_NOT_ALLOWED` | 修正 GET/POST 方法 | +| `AUTH_REQUIRED` | 恢复原小程序登录 | +| `STORAGE_UNAVAILABLE` | 保留本机,稍后按同请求重试或读取确认 | + +原登录中间件会更早返回 `code=-1`(过期)或 `code=0,data=[]`(缺 token)。此既有格式未改动,前端也要处理,不能假设所有错误都有 `error_code`。 + +网络请求复用主小程序 `token`,采用有限超时(现有 Vue3 封装默认 15 秒);传 JSON 请求头覆盖默认表单编码。单用户保存排队、合并频繁游标变动;只对暂时网络/存储失败进行有限退避重试,并复用原请求 ID、原正文。这个模块未新增集中限流器;对公网部署前仍须按实际网关配置用户级速率限制,不能把前端节流当成服务端限流。 + +## 数据隔离、迁移与可观测性 + +仅新增 `zyt_tcm_tang_detective_progress`,逻辑访问名 `tcm_tang_detective_progress`。每用户唯一行;读取不自动建行。写入通过事务与 `SELECT ... FOR UPDATE` 锁定自己的行,更新还检查修订/代次。首次并发插入由 `UNIQUE(user_id)` 仲裁;重复键、死锁或锁等待失败映射为 `PROGRESS_CONFLICT`,不向客户端暴露数据库详情。无 upsert 覆盖路径。 + +迁移文件:`sql/1.9.20260908/add_tang_detective_progress.sql`。脚本沿用默认 `zyt_` 前缀、InnoDB、`utf8mb4` 与整数时间戳;在执行前核对部署的 `database.prefix`。只追加表,不回填其他业务数据。发布顺序是先审核/执行新增表迁移,再启用接口,最后启用前端同步。回退时先停入口/回退代码,保留新增表和用户存档;不要自动删除存档。 + +错误日志只记录稳定事件名、操作、当前用户 ID 与异常类,不记录请求体、token、SQL、原始异常消息或正文。未实现外部监控仪表盘或迁移自动执行。进度行关联账号;不复制患者、手机、健康回答。独立保留策略及账号删除时的存档清理须随实际账号生命周期流程审定,本次不修改共享用户删除流程。 + +## 检查方式和交付边界 + +不需要 Composer、数据库或网络的契约脚本: + +```sh +php server/tests/TangDetectiveProgressContractTest.php +php server/tests/TangDetectivePersistenceContractTest.php +``` + +第一项覆盖十五章全部连续事件前缀、页锁、未知字段/健康正文拒绝、32 KiB 边界、幂等、版本冲突及重置后旧代次。第二项通过内存替身执行真实持久化/控制器代码,检查身份隔离、行锁调用、仅访问独立表、重复提交不更新、数据库异常脱敏、方法与媒体类型保护。 + +已使用受限、只读 Node 模块加载器逐章核对原 source 的 15 章、60 事件、120 页和 15 卡 ID,结果通过。该核对不执行 PHP 后端。 + +截至本次编写环境,`php` 不在 PATH,也未在三个常用可执行路径找到。本轮 PHP 测试状态为 **NOT RUN(未运行)**;已写测试不等于通过。内存替身即使通过,也不证明真实 MySQL 隔离级别、死锁行为、中间件认证、HTTP 路由或目标部署可用。真实数据库并发、登录到请求端点、微信真机和部署验证均未运行。本轮不执行迁移、不启动服务、不安装依赖或访问网络。 diff --git a/server/docs/tang-detective.openapi.yaml b/server/docs/tang-detective.openapi.yaml new file mode 100644 index 0000000..d59f69b --- /dev/null +++ b/server/docs/tang-detective.openapi.yaml @@ -0,0 +1,240 @@ +openapi: 3.1.0 +info: + title: 唐侦探独立章节存档 + version: 1.0.0 + description: >- + 使用原小程序 token,仅操作登录用户自己的 season-01 ID 存档。 + 不接收健康回答、正文、快照或客户端用户身份。业务错误沿用 HTTP 200/code 约定。 +servers: + - url: /api +security: + - MiniProgramToken: [] +paths: + /tang/catalog: + get: + operationId: getTangDetectiveCatalog + summary: 读取部署版本的 ID 白名单 + responses: + '200': + description: code=1 为目录,code=0/-1 为错误 + content: + application/json: + schema: + oneOf: + - type: object + required: [code, show, msg, data] + properties: + code: {const: 1} + show: {const: 0} + msg: {type: string} + data: {$ref: '#/components/schemas/Catalog'} + - {$ref: '#/components/schemas/Failure'} + /tang/progress: + get: + operationId: getTangDetectiveProgress + summary: 只读当前登录用户存档,无存档返回 revision=0 的默认值 + responses: + '200': + $ref: '#/components/responses/ProgressResponse' + /tang/saveProgress: + post: + operationId: saveTangDetectiveProgress + summary: 版本匹配时替换整份 ID 投影或重新开始故事 + description: >- + 原始请求体最多 32768 字节。最后一次 request_id 的相同规范化内容可幂等重试。 + 必须匹配 base_revision 和 story_generation;冲突不覆盖、不自动合并。 + reset_story 必须提交空故事字段,收藏为当前服务器与该请求的已验证卡 ID 并集。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/SaveRequest'} + responses: + '200': + $ref: '#/components/responses/ProgressResponse' +components: + securitySchemes: + MiniProgramToken: + type: apiKey + in: header + name: token + description: 沿用现有 LoginMiddleware;不是 Authorization,也不放入请求正文。 + responses: + ProgressResponse: + description: >- + 必须检查 code;code=1 的 data 是本人确认存档。 + 此控制器的响应含 Cache-Control no-store。 + headers: + Cache-Control: + schema: {type: string, const: no-store} + content: + application/json: + schema: + oneOf: + - {$ref: '#/components/schemas/ProgressSuccess'} + - {$ref: '#/components/schemas/Failure'} + schemas: + ChapterId: + type: string + pattern: '^S01-C(0[1-9]|1[0-5])$' + EventId: + type: string + pattern: '^S01-H(0[1-9]|[1-5][0-9]|60)$' + PageId: + type: string + pattern: '^S01-C(0[1-9]|1[0-5])-P0[1-8]$' + CardId: + type: string + pattern: '^S01-C(0[1-9]|1[0-5])-MC01$' + Catalog: + type: object + additionalProperties: false + required: [game_id, schema_version, content_version, max_body_bytes, chapters] + properties: + game_id: {const: tang-detective} + schema_version: {const: 1} + content_version: {const: season-01} + max_body_bytes: {const: 32768} + chapters: + type: array + minItems: 15 + maxItems: 15 + items: + type: object + additionalProperties: false + required: [chapter_id, chapter_number, hotspot_ids, page_ids, memory_card_id] + properties: + chapter_id: {$ref: '#/components/schemas/ChapterId'} + chapter_number: {type: integer, minimum: 1, maximum: 15} + hotspot_ids: + type: array + minItems: 4 + maxItems: 4 + uniqueItems: true + items: {$ref: '#/components/schemas/EventId'} + page_ids: + type: array + minItems: 8 + maxItems: 8 + uniqueItems: true + items: {$ref: '#/components/schemas/PageId'} + memory_card_id: {$ref: '#/components/schemas/CardId'} + Reader: + type: object + additionalProperties: false + required: [currentPageId, completedEventIds, chapterFinished] + properties: + currentPageId: {$ref: '#/components/schemas/PageId'} + completedEventIds: + type: array + maxItems: 4 + uniqueItems: true + items: {$ref: '#/components/schemas/EventId'} + chapterFinished: {type: boolean} + description: >- + 事件必须是所属章节的连续前缀且与 completedHotspots 相同; + chapterFinished 必须与 completedChapters 成员关系一致,为 true 时须有四事件。 + 未 finished 仅允许 P01 至 P(03+事件数),最多 P07;P08 必须 finished。 + Progress: + type: object + additionalProperties: false + required: [completedHotspots, completedChapters, lastChapter, collectedMemoryCards, comicReaderByChapter, lastPageId] + properties: + completedHotspots: + type: object + maxProperties: 15 + propertyNames: {$ref: '#/components/schemas/ChapterId'} + additionalProperties: + type: array + maxItems: 4 + uniqueItems: true + items: {$ref: '#/components/schemas/EventId'} + completedChapters: + type: array + maxItems: 15 + uniqueItems: true + items: {$ref: '#/components/schemas/ChapterId'} + lastChapter: {type: integer, minimum: 1, maximum: 15} + collectedMemoryCards: + type: array + maxItems: 15 + uniqueItems: true + items: {$ref: '#/components/schemas/CardId'} + comicReaderByChapter: + type: object + maxProperties: 15 + propertyNames: {$ref: '#/components/schemas/ChapterId'} + additionalProperties: {$ref: '#/components/schemas/Reader'} + lastPageId: + oneOf: + - {const: ''} + - {$ref: '#/components/schemas/PageId'} + description: >- + 空映射必须是 {},不是 []。有已完成事件或 finished 的章节必须有 reader。 + 非空 lastPageId 必须等于 lastChapter 对应 reader.currentPageId。 + 跨字段成员关系、顺序和解锁约束由 TangDetectiveProgress 验证。 + SaveRequest: + type: object + additionalProperties: false + required: [schema_version, content_version, base_revision, story_generation, request_id, operation, progress] + properties: + schema_version: {type: integer, const: 1} + content_version: {const: season-01} + base_revision: {type: integer, minimum: 0, maximum: 2147483646} + story_generation: {type: integer, minimum: 0, maximum: 2147483646} + request_id: {type: string, pattern: '^[A-Za-z0-9_-]{16,64}$'} + operation: {enum: [replace, reset_story]} + progress: {$ref: '#/components/schemas/Progress'} + allOf: + - if: + properties: + operation: {const: reset_story} + then: + properties: + progress: + properties: + completedHotspots: {maxProperties: 0} + completedChapters: {maxItems: 0} + lastChapter: {const: 1} + comicReaderByChapter: {maxProperties: 0} + lastPageId: {const: ''} + State: + type: object + additionalProperties: false + required: [user_id, schema_version, content_version, revision, story_generation, progress] + properties: + user_id: {type: integer, minimum: 1} + schema_version: {const: 1} + content_version: {const: season-01} + revision: {type: integer, minimum: 0} + story_generation: {type: integer, minimum: 0} + progress: {$ref: '#/components/schemas/Progress'} + idempotent: + type: boolean + description: 仅保存响应提供;true 表示重复确认最后一次提交,未重复写入。 + ProgressSuccess: + type: object + required: [code, show, msg, data] + properties: + code: {const: 1} + show: {const: 0} + msg: {type: string} + data: {$ref: '#/components/schemas/State'} + Failure: + type: object + required: [code, show, msg, data] + properties: + code: {type: integer, enum: [0, -1]} + show: {type: integer, enum: [0, 1]} + msg: {type: string} + data: + oneOf: + - type: object + additionalProperties: false + required: [error_code] + properties: + error_code: + enum: [INVALID_REQUEST, PAYLOAD_TOO_LARGE, UNSUPPORTED_CONTENT_VERSION, PROGRESS_CONFLICT, IDEMPOTENCY_CONFLICT, METHOD_NOT_ALLOWED, UNSUPPORTED_MEDIA_TYPE, AUTH_REQUIRED, STORAGE_UNAVAILABLE] + - type: array + maxItems: 0 + description: 原登录中间件可能提前返回空 data;登录过期 code=-1。 diff --git a/server/sql/1.9.20260908/add_tang_detective_progress.sql b/server/sql/1.9.20260908/add_tang_detective_progress.sql new file mode 100644 index 0000000..4efa3e9 --- /dev/null +++ b/server/sql/1.9.20260908/add_tang_detective_progress.sql @@ -0,0 +1,17 @@ +-- 唐侦探 season-01 独立存档。仅新增表;不修改用户、患者或三消业务表。 +-- 默认前缀 zyt_;部署时须与 database.prefix 核对。不要对线上库自动执行。 +CREATE TABLE IF NOT EXISTS `zyt_tcm_tang_detective_progress` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL COMMENT '统一 token 对应的小程序用户 ID', + `schema_version` smallint unsigned NOT NULL DEFAULT 1, + `content_version` varchar(32) NOT NULL DEFAULT 'season-01', + `revision` int unsigned NOT NULL DEFAULT 1 COMMENT '成功写入递增,旧修订不能覆盖', + `story_generation` int unsigned NOT NULL DEFAULT 0 COMMENT '重新开始故事时递增', + `progress_json` text NOT NULL COMMENT '严格白名单 ID 及游标,不含正文或健康选择', + `last_request_id` varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `last_request_hash` char(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `create_time` int unsigned NOT NULL DEFAULT 0, + `update_time` int unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tang_progress_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='唐侦探用户独立章节存档'; diff --git a/server/tests/TangDetectivePersistenceContractTest.php b/server/tests/TangDetectivePersistenceContractTest.php new file mode 100644 index 0000000..cb48629 --- /dev/null +++ b/server/tests/TangDetectivePersistenceContractTest.php @@ -0,0 +1,266 @@ + ['Driver Error Code' => $this->getCode()]]; + } + } +} + +namespace think\facade { + final class Db + { + public static array $rows = []; + public static array $snapshot = []; + public static bool $transaction = false; + public static ?int $lockedUser = null; + public static int $insertError = 0; + public static int $writes = 0; + + public static function name(string $table): TangQuery + { + if ($table !== 'tcm_tang_detective_progress') { + throw new \RuntimeException('Attempted unrelated business table access'); + } + return new TangQuery(); + } + + public static function startTrans(): void + { + if (self::$transaction) { + throw new \RuntimeException('Leaked transaction'); + } + self::$snapshot = self::$rows; + self::$transaction = true; + } + + public static function commit(): void + { + self::$transaction = false; + self::$lockedUser = null; + } + + public static function rollback(): void + { + self::$rows = self::$snapshot; + self::commit(); + } + } + + final class TangQuery + { + private array $conditions = []; + private bool $locked = false; + + public function where(string $field, $value): self + { + $this->conditions[$field] = $value; + return $this; + } + + public function lock(bool $locked): self + { + $this->locked = $locked; + return $this; + } + + public function find(): ?array + { + if (!isset($this->conditions['user_id'])) { + throw new \RuntimeException('Query omitted server user identity'); + } + if ($this->locked) { + if (!Db::$transaction) { + throw new \RuntimeException('Row lock requires transaction'); + } + Db::$lockedUser = $this->conditions['user_id']; + } + return Db::$rows[$this->conditions['user_id']] ?? null; + } + + public function insert(array $values): int + { + if (!Db::$transaction || Db::$lockedUser !== $values['user_id']) { + throw new \RuntimeException('Insert must follow own-user locked lookup'); + } + if (Db::$insertError) { + $code = Db::$insertError; + Db::$insertError = 0; + throw new \think\db\exception\PDOException('SECRET_SQL_AND_PRIVATE_PAYLOAD', $code); + } + if (isset(Db::$rows[$values['user_id']])) { + throw new \think\db\exception\PDOException('SECRET_DUPLICATE_SQL', 1062); + } + Db::$rows[$values['user_id']] = $values; + ++Db::$writes; + return 1; + } + + public function update(array $values): int + { + $userId = $this->conditions['user_id'] ?? 0; + if (!Db::$transaction || Db::$lockedUser !== $userId) { + throw new \RuntimeException('Update requires own-user lock'); + } + $row = Db::$rows[$userId] ?? []; + if (!isset($this->conditions['revision'], $this->conditions['story_generation']) + || ($row['revision'] ?? null) !== $this->conditions['revision'] + || ($row['story_generation'] ?? null) !== $this->conditions['story_generation']) { + return 0; + } + Db::$rows[$userId] = array_merge($row, $values); + ++Db::$writes; + return 1; + } + } + + final class Log + { + public static array $entries = []; + + public static function warning(string $message, array $context): void + { + self::$entries[] = [$message, $context]; + } + } +} + +namespace app\api\controller { + final class TangTestResponse extends \ArrayObject + { + public array $headers = []; + public function header(array $headers): self + { + $this->headers = $headers; + return $this; + } + } + + class BaseApiController + { + protected int $userId; + protected object $request; + + public function __construct(int $userId, object $request) + { + $this->userId = $userId; + $this->request = $request; + } + + protected function data(array $data): TangTestResponse + { + return new TangTestResponse(['code' => 1, 'data' => $data]); + } + + protected function fail(string $message, array $data = [], int $code = 0, int $show = 0): TangTestResponse + { + return new TangTestResponse(['code' => $code, 'show' => $show, 'msg' => $message, 'data' => $data]); + } + } +} + +namespace { + require __DIR__ . '/../app/common/service/game/TangDetectiveProgressException.php'; + require __DIR__ . '/../app/common/service/game/TangDetectiveProgress.php'; + require __DIR__ . '/../app/api/logic/tcm/TangDetectiveLogic.php'; + require __DIR__ . '/../app/api/controller/TangDetectiveController.php'; + + use app\api\controller\TangDetectiveController; + use app\api\logic\tcm\TangDetectiveLogic; + use app\common\service\game\TangDetectiveProgress; + use app\common\service\game\TangDetectiveProgressException; + use think\facade\Db; + use think\facade\Log; + + $assertions = 0; + $expect = static function (bool $condition, string $message) use (&$assertions): void { + if (!$condition) { + throw new RuntimeException($message); + } + ++$assertions; + }; + $error = static function (string $code, callable $action) use ($expect): void { + try { + $action(); + } catch (TangDetectiveProgressException $exception) { + $expect($exception->errorCode() === $code, 'stable error: ' . $code); + $expect(!str_contains($exception->getMessage(), 'SECRET'), 'database detail stays private'); + return; + } + throw new RuntimeException('Missing error: ' . $code); + }; + $requestObject = static function (string $method, string $raw = '', string $type = 'application/json'): object { + return new class($method, $raw, $type) { + public function __construct(private string $method, private string $raw, private string $type) {} + public function isGet(): bool { return $this->method === 'GET'; } + public function isPost(): bool { return $this->method === 'POST'; } + public function contentType(): string { return $this->type; } + public function getContent(): string { return $this->raw; } + }; + }; + $policy = new TangDetectiveProgress(); + $logic = new TangDetectiveLogic($policy); + $body = [ + 'schema_version' => 1, 'content_version' => 'season-01', + 'base_revision' => 0, 'story_generation' => 0, + 'request_id' => 'own_user_request_001', 'operation' => 'replace', + 'progress' => $policy->defaultProgress(), + ]; + $raw = json_encode($body, JSON_THROW_ON_ERROR); + $command = $policy->decodeRequest($raw); + $expect($logic->read(11)['revision'] === 0 && Db::$rows === [], 'empty read makes no database writes'); + $save = $logic->save(11, $command); + $expect($save['revision'] === 1 && $save['user_id'] === 11, 'server identity owns first save'); + $expect(!Db::$transaction && Db::$writes === 1, 'first save commits one write'); + $save = $logic->save(11, $command); + $expect($save['idempotent'] && Db::$writes === 1, 'same request retry performs no update'); + $expect($logic->read(12)['revision'] === 0, 'different user cannot read first user state'); + $logic->save(12, $command); + $expect(count(Db::$rows) === 2, 'same request ID may be used by independent users'); + $before = Db::$rows[11]; + $stale = $command; + $stale['request_id'] = 'stale_request_00001'; + $error('PROGRESS_CONFLICT', static fn () => $logic->save(11, $stale)); + $expect(Db::$rows[11] === $before && !Db::$transaction, 'stale update rolls back without overwrite'); + $next = $command; + $next['base_revision'] = 1; + $next['request_id'] = 'second_request_0001'; + $expect($logic->save(11, $next)['revision'] === 2, 'matching revision updates under lock'); + $error('PROGRESS_CONFLICT', static fn () => $logic->save(11, $command)); + foreach ([1062, 1205, 1213] as $code) { + Db::$insertError = $code; + $error('PROGRESS_CONFLICT', static fn () => $logic->save(20, $command)); + $expect(!isset(Db::$rows[20]) && !Db::$transaction, 'first-insert race leaves no partial state'); + } + Db::$insertError = 1146; + $error('STORAGE_UNAVAILABLE', static fn () => $logic->save(20, $command)); + $expect(!str_contains(json_encode(Log::$entries), 'SECRET'), 'logs exclude SQL and raw exception text'); + $error('AUTH_REQUIRED', static fn () => $logic->read(0)); + Db::$rows[12]['progress_json'] = '{"healthAnswer":"SECRET_PRIVATE_TEXT"}'; + $error('STORAGE_UNAVAILABLE', static fn () => $logic->read(12)); + $expect(!str_contains(json_encode(Log::$entries), 'SECRET'), 'corrupt stored text never enters logs'); + + $controller = new TangDetectiveController(11, $requestObject('GET')); + $expect($controller->progress()['data']['user_id'] === 11, 'controller returns only server identity'); + $expect(count($controller->catalog()['data']['chapters']) === 15, 'catalog has no storage dependency'); + $expect($controller->saveProgress()['data']['error_code'] === 'METHOD_NOT_ALLOWED', 'conventional GET save route cannot write'); + $controller = new TangDetectiveController(0, $requestObject('GET')); + $expect($controller->catalog()['data']['error_code'] === 'AUTH_REQUIRED', 'catalog cannot bypass own-user requirement'); + $controller = new TangDetectiveController(11, $requestObject('POST', $raw, 'text/plain')); + $expect($controller->saveProgress()['data']['error_code'] === 'UNSUPPORTED_MEDIA_TYPE', 'save rejects non-JSON media type'); + $forged = $body + ['user_id' => 99]; + $controller = new TangDetectiveController(11, $requestObject('POST', json_encode($forged))); + $expect($controller->saveProgress()['data']['error_code'] === 'INVALID_REQUEST', 'client identity field is rejected'); + $controller = new TangDetectiveController(31, $requestObject('POST', $raw)); + $result = $controller->saveProgress(); + $expect($result['code'] === 1 && $result['data']['user_id'] === 31, 'controller passes only injected identity to persistence'); + $expect($result->headers['Cache-Control'] === 'no-store', 'own-user state is never shared-cacheable'); + echo "Tang Detective persistence/controller doubles: {$assertions} assertions OK\n"; +} diff --git a/server/tests/TangDetectiveProgressContractTest.php b/server/tests/TangDetectiveProgressContractTest.php new file mode 100644 index 0000000..485a8d8 --- /dev/null +++ b/server/tests/TangDetectiveProgressContractTest.php @@ -0,0 +1,170 @@ +errorCode() === $code, 'expected stable error: ' . $code); + return; + } + throw new RuntimeException('Expected error was not thrown: ' . $code); +}; +$encode = static fn (array $value): string => json_encode($value, JSON_THROW_ON_ERROR); +$command = static function (array $progress, int $revision = 0, int $generation = 0, string $id = 'test_request_00000001', string $operation = 'replace'): array { + return [ + 'schema_version' => 1, 'content_version' => 'season-01', + 'base_revision' => $revision, 'story_generation' => $generation, + 'request_id' => $id, 'operation' => $operation, 'progress' => $progress, + ]; +}; +$progressFor = static function (int $chapterNumber, int $events, bool $finished = false) use ($policy): array { + $id = sprintf('S01-C%02d', $chapterNumber); + $catalog = $policy->catalog()['chapters'][$chapterNumber - 1]; + $done = array_slice($catalog['hotspot_ids'], 0, $events); + $page = sprintf('%s-P%02d', $id, $finished ? 8 : min(7, 3 + $events)); + return [ + 'completedHotspots' => (object) [$id => $done], + 'completedChapters' => $finished ? [$id] : [], + 'lastChapter' => $chapterNumber, + 'collectedMemoryCards' => [], + 'comicReaderByChapter' => (object) [$id => (object) [ + 'currentPageId' => $page, 'completedEventIds' => $done, 'chapterFinished' => $finished, + ]], + 'lastPageId' => $page, + ]; +}; + +$initial = $policy->defaultState(11); +$expect($initial['user_id'] === 11 && $initial['revision'] === 0, 'own-user empty read is revision zero'); +$expect($initial['progress']['completedHotspots'] instanceof stdClass, 'empty maps serialize as objects'); +$expectError('AUTH_REQUIRED', static fn () => $policy->defaultState(0)); +$expect(count($policy->catalog()['chapters']) === 15, 'catalog contains 15 chapters'); +for ($chapter = 1; $chapter <= 15; ++$chapter) { + for ($count = 0; $count <= 4; ++$count) { + $parsed = $policy->decodeRequest($encode($command($progressFor($chapter, $count)))); + $id = sprintf('S01-C%02d', $chapter); + $expect(count($parsed['progress']['completedHotspots']->$id) === $count, 'all canonical prefixes accepted'); + $expect($parsed['progress']['completedChapters'] === [], 'four events do not finish a chapter automatically'); + } + $parsed = $policy->decodeRequest($encode($command($progressFor($chapter, 4, true)))); + $expect(count($parsed['progress']['completedChapters']) === 1, 'explicit emotion completion permits memory page'); +} + +$valid = $command($progressFor(1, 1)); +$request = $policy->decodeRequest($encode($valid)); +$saved = $policy->transition($initial, $request); +$expect($saved['state']['revision'] === 1 && !$saved['idempotent'], 'first save advances exactly one revision'); +$retry = $policy->transition($saved['state'], $request, $request['request_id'], $saved['request_hash']); +$expect($retry['idempotent'] && $retry['state'] === $saved['state'], 'acknowledgement loss retries are idempotent'); +$changed = $request; +$changed['progress']['lastPageId'] = ''; +$expectError('IDEMPOTENCY_CONFLICT', static fn () => $policy->transition( + $saved['state'], $changed, $request['request_id'], $saved['request_hash'] +)); +$racing = $request; +$racing['request_id'] = 'other_device_00000001'; +$expectError('PROGRESS_CONFLICT', static fn () => $policy->transition($saved['state'], $racing)); +$expect($saved['state']['progress']['lastPageId'] === 'S01-C01-P04', 'conflicting copy never changes confirmed state'); + +$withCard = $progressFor(1, 4, true); +$withCard['collectedMemoryCards'] = ['S01-C01-MC01']; +$current = $policy->transition($initial, $policy->decodeRequest($encode($command($withCard))))['state']; +$resetProgress = $policy->defaultProgress(); +$resetProgress['collectedMemoryCards'] = ['S01-C02-MC01']; +$resetRequest = $policy->decodeRequest($encode($command($resetProgress, 1, 0, 'reset_request_000001', 'reset_story'))); +$reset = $policy->transition($current, $resetRequest); +$expect($reset['state']['progress']['collectedMemoryCards'] === ['S01-C01-MC01', 'S01-C02-MC01'], 'reset preserves cloud and unsynced local card IDs'); +$expect($reset['state']['story_generation'] === 1 && $reset['state']['revision'] === 2, 'reset advances generation and revision'); +$expect(get_object_vars($reset['state']['progress']['comicReaderByChapter']) === [], 'reset empties reader bookmarks'); +$resetRetry = $policy->transition($reset['state'], $resetRequest, $resetRequest['request_id'], $reset['request_hash']); +$expect($resetRetry['state']['story_generation'] === 1 && $resetRetry['idempotent'], 'reset retry never increments generation twice'); +$oldGeneration = $command($withCard, 2, 0, 'old_device_000000001'); +$expectError('PROGRESS_CONFLICT', static fn () => $policy->transition($reset['state'], $policy->decodeRequest($encode($oldGeneration)))); +$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($withCard, 1, 0, 'reset_request_000002', 'reset_story')))); + +$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest('[]')); +$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest('{invalid')); +$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest('null')); +$expectError('PAYLOAD_TOO_LARGE', static fn () => $policy->decodeRequest(str_repeat(' ', 32769))); +$exact = $encode($valid); +$exact .= str_repeat(' ', 32768 - strlen($exact)); +$expect($policy->decodeRequest($exact)['request_id'] === $valid['request_id'], 'exactly 32 KiB allowed'); + +$invalidBodies = []; +$body = $valid; +$body['user_id'] = 99; +$invalidBodies[] = $body; +$body = $valid; +$body['progress']['memoryCardSnapshots'] = (object) ['private' => 'must not be stored']; +$invalidBodies[] = $body; +$body = $valid; +$body['progress']['healthAnswer'] = 'private'; +$invalidBodies[] = $body; +$body = $valid; +$body['progress']['lastChapter'] = '1'; +$invalidBodies[] = $body; +$body = $valid; +$body['progress']['completedHotspots'] = []; +$invalidBodies[] = $body; +$body = $valid; +$body['base_revision'] = -1; +$invalidBodies[] = $body; +$body = $valid; +$body['request_id'] = 'short'; +$invalidBodies[] = $body; +$body = $valid; +$body['progress']['collectedMemoryCards'] = ['S01-C16-MC01']; +$invalidBodies[] = $body; +$body = $valid; +$body['progress']['collectedMemoryCards'] = ['S01-C01-MC01', 'S01-C01-MC01']; +$invalidBodies[] = $body; +foreach ($invalidBodies as $body) { + $expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($body))); +} +foreach ([['S01-H02'], ['S01-H01', 'S01-H03'], ['S01-H02', 'S01-H01'], ['S01-H01', 'S01-H01'], ['S01-H05']] as $badPrefix) { + $progress = $progressFor(1, 1); + $progress['completedHotspots']->{'S01-C01'} = $badPrefix; + $expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress)))); +} +$progress = $progressFor(1, 3); +$progress['completedChapters'] = ['S01-C01']; +$progress['comicReaderByChapter']->{'S01-C01'}->chapterFinished = true; +$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress)))); +$progress = $progressFor(1, 4); +$progress['comicReaderByChapter']->{'S01-C01'}->currentPageId = 'S01-C01-P08'; +$progress['lastPageId'] = 'S01-C01-P08'; +$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress)))); +$progress = $progressFor(1, 1); +$progress['comicReaderByChapter']->{'S01-C01'}->answer = 'private'; +$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress)))); +$progress = $progressFor(1, 1); +$progress['comicReaderByChapter']->{'S01-C01'}->completedEventIds = []; +$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress)))); +$progress = $progressFor(1, 1); +$progress['lastPageId'] = 'S01-C02-P01'; +$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress)))); +$body = $valid; +$body['schema_version'] = '1'; +$expectError('UNSUPPORTED_CONTENT_VERSION', static fn () => $policy->decodeRequest($encode($body))); +$body = $valid; +$body['content_version'] = 'season-02'; +$expectError('UNSUPPORTED_CONTENT_VERSION', static fn () => $policy->decodeRequest($encode($body))); + +echo "Tang Detective progress contract: {$assertions} assertions OK\n";